taler-android

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

commit 2dfe18a51eaff5e5c79a787890d1952f98ff89d3
parent c2c76fb8a09cd9f846f938cc9053b26810d99785
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 10 Jun 2026 00:22:08 +0200

[wallet] implement Vlada's new balances screen

Diffstat:
Mwallet/build.gradle | 4++--
Mwallet/src/main/java/net/taler/wallet/WalletNavHost.kt | 19+++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/WalletNavigation.kt | 9+++++++++
Mwallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt | 40++++++++++++++--------------------------
Dwallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt | 470-------------------------------------------------------------------------------
Awallet/src/main/java/net/taler/wallet/balances/BalancesListComposable.kt | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/balances/BalancesPagerComposable.kt | 805+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/compose/ActionButton.kt | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt | 3+--
Mwallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingScreen.kt | 3+--
Mwallet/src/main/java/net/taler/wallet/main/MainScreen.kt | 452+++++++++++++++++++------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/main/MainViewModel.kt | 71+++++++++++++++++++++++++++++++++++------------------------------------
Dwallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt | 253-------------------------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/main/ViewMode.kt | 54++++++++++++++----------------------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPullScreen.kt | 5++---
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushScreen.kt | 5++---
Mwallet/src/main/java/net/taler/wallet/scan/ScanQrScreen.kt | 72++++++++++++++++++++++++++++--------------------------------------------
Mwallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt | 29++++++++++++-----------------
Mwallet/src/main/java/net/taler/wallet/settings/SettingsScreen.kt | 421+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt | 187+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mwallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt | 64++++++++++++++++------------------------------------------------
Awallet/src/main/java/net/taler/wallet/transactions/TransactionsScreen.kt | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt | 9+++------
Mwallet/src/main/proto/user_prefs.proto | 7+++++++
Mwallet/src/main/res/values/strings.xml | 2++
26 files changed, 2008 insertions(+), 1569 deletions(-)

diff --git a/wallet/build.gradle b/wallet/build.gradle @@ -131,8 +131,8 @@ dependencies { implementation "androidx.biometric:biometric-ktx:1.4.0-alpha02" // Compose - implementation platform('androidx.compose:compose-bom:2026.04.01') - implementation 'androidx.compose.material3:material3' + implementation platform('androidx.compose:compose-bom:2026.05.01') + implementation 'androidx.compose.material3:material3:1.5.0-alpha21' implementation 'androidx.compose.material:material-icons-extended' implementation "androidx.compose.runtime:runtime-livedata" implementation "androidx.lifecycle:lifecycle-viewmodel-compose" diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -58,9 +58,11 @@ import net.taler.wallet.peer.OutgoingPullScreen import net.taler.wallet.peer.OutgoingPushScreen import net.taler.wallet.scan.ScanQrScreen import net.taler.wallet.settings.PerformanceStatsScreen +import net.taler.wallet.settings.SettingsScreen import net.taler.wallet.tokens.TokenListScreen import net.taler.wallet.tokens.TokenViewMode import net.taler.wallet.transactions.TransactionDetailScreen +import net.taler.wallet.transactions.TransactionsScreen import net.taler.wallet.transfer.WireTransferDetailsScreen import net.taler.wallet.withdraw.PromptWithdrawScreen @@ -128,6 +130,23 @@ fun WalletNavHost( model = model, onNavigate = onNavigate, onFulfillPayment = onFulfillPayment, + ) + } + composable<WalletDestination.Transactions> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.Transactions>() + TransactionsScreen( + model = model, + filter = dest.filter, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + onShowError = onShowError, + ) + } + composable<WalletDestination.Settings> { + SettingsScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, onShowError = onShowError, ) } diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt @@ -18,6 +18,7 @@ package net.taler.wallet import kotlinx.serialization.Serializable import net.taler.wallet.scan.ScanTab +import net.taler.wallet.transactions.TransactionStateFilter @Serializable sealed interface WalletDestination { @@ -28,6 +29,14 @@ sealed interface WalletDestination { data class ScanQr(val initialTab: ScanTab = ScanTab.SCAN_QR) : WalletDestination @Serializable + data class Transactions( + val filter: TransactionStateFilter? = null, + ) : WalletDestination + + @Serializable + data object Settings : WalletDestination + + @Serializable data class HandleUri(val uri: String) : WalletDestination @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt @@ -47,12 +47,12 @@ sealed class WalletResponse<T> { val error: TalerErrorInfo, ) : WalletResponse<T>() - fun onSuccess(block: (result: T) -> Unit): WalletResponse<T> { + suspend fun onSuccess(block: suspend (result: T) -> Unit): WalletResponse<T> { if (this is Success) block(this.result) return this } - fun onError(block: (result: TalerErrorInfo) -> Unit): WalletResponse<T> { + suspend fun onError(block: suspend (result: TalerErrorInfo) -> Unit): WalletResponse<T> { if (this is Error) block(this.error) return this } diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -29,8 +29,6 @@ import net.taler.common.CurrencySpecification import net.taler.wallet.main.TAG import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi -import net.taler.wallet.backend.WalletResponse -import net.taler.wallet.balances.ScopeInfo.ExchangeLegacyKeys import net.taler.wallet.donau.DonauSummaryItem import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeManager @@ -79,16 +77,13 @@ class BalanceManager( fun loadAssets(loading: Boolean = false) = scope.launch { if (loading) mState.postValue(BalanceState.Loading) - when (val response = api.request("getBalances", BalanceResponse.serializer())) { - is WalletResponse.Error -> { - Log.e(TAG, "Error retrieving balances: ${response.error}") - mState.postValue(BalanceState.Error(response.error)) - } - - is WalletResponse.Success -> { - val balances = response.result.balances.map { balance -> - val spec = exchangeManager - .getCurrencySpecification(balance.scopeInfo) + api.request("getBalances", BalanceResponse.serializer()) + .onError { + Log.e(TAG, "Error retrieving balances: $it") + mState.postValue(BalanceState.Error(it)) + }.onSuccess { res -> + val balances = res.balances.map { balance -> + val spec = exchangeManager.getCurrencySpecification(balance.scopeInfo) balance.copy( available = balance.available.withSpec(spec), pendingIncoming = balance.pendingIncoming.withSpec(spec), @@ -96,9 +91,8 @@ class BalanceManager( ) } - val donauSummary = response.result.donauSummary?.map { item -> - val spec = exchangeManager - .getSpecForCurrency(item.amountReceiptsAvailable.currency) + val donauSummary = res.donauSummary?.map { item -> + val spec = exchangeManager.getSpecForCurrency(item.amountReceiptsAvailable.currency) item.copy( amountReceiptsAvailable = item.amountReceiptsAvailable.withSpec(spec), amountReceiptsSubmitted = item.amountReceiptsSubmitted.withSpec(spec), @@ -112,22 +106,21 @@ class BalanceManager( donauSummary = donauSummary, )) } - } } @UiThread - fun getCurrencies() = spendableBalances().map { balanceItem -> + fun getCurrencies() = balances.value?.map { balanceItem -> balanceItem.currency - } + } ?: emptyList() @UiThread - fun getScopes(forPeer: Boolean = false) = spendableBalances().filter { + fun getScopes(forPeer: Boolean = false) = balances.value?.filter { !forPeer || !it.disablePeerPayments - }.map { it.scopeInfo } + }?.map { it.scopeInfo } ?: emptyList() @UiThread fun hasSufficientBalance(amount: Amount): Boolean { - spendableBalances().forEach { balanceItem -> + balances.value?.forEach { balanceItem -> if (balanceItem.currency == amount.currency) { return balanceItem.available >= amount } @@ -135,10 +128,6 @@ class BalanceManager( return false } - private fun spendableBalances() = balances.value - ?.filter { it.scopeInfo !is ExchangeLegacyKeys } - ?: emptyList() - fun addGlobalCurrencyExchange( currency: String, exchange: ExchangeItem, @@ -177,6 +166,5 @@ class BalanceManager( fun resetBalances() { mState.value = BalanceState.None - loadAssets() } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt @@ -1,469 +0,0 @@ -/* - * 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 - -// TODO: rename to AssetsComposable -@Composable -fun BalancesComposable( - innerPadding: PaddingValues, - state: BalanceState, - devMode: Boolean, - networkStatus: Boolean, - onWithdrawMoneyClicked: () -> Unit, - onGetDemoMoneyClicked: () -> Unit, - onBalanceClicked: (balance: BalanceItem) -> Unit, - onPendingClicked: (balance: BalanceItem) -> Unit, - onStatementClicked: (host: String) -> Unit, - onShowDiscounts: () -> Unit, - onShowPasses: () -> Unit, -) { - 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 -> - BalanceRow( - balance, - onClick = { onBalanceClicked(balance) }, - onPendingClick = { onPendingClicked(balance) }, - ) - } - - if (state.donauSummary.isNotEmpty()) stickyHeader { - SectionHeader { Text(stringResource(R.string.balances_section_statements)) } - } - - items(state.donauSummary) { statement -> - StatementRow( - statement, - onClick = { onStatementClicked(statement.donauBaseUrl) }, - ) - } - - stickyHeader { - SectionHeader { Text(stringResource(R.string.balances_section_tokens)) } - } - - 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, - ) - }, - ) - } - } - - 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 SectionHeader( - 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 BalanceRow( - balance: BalanceItem, - onClick: () -> Unit, - onPendingClick: () -> Unit, -) { - 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) { - LegacyBalanceWarningTooltip() - } - } - ) - - if (!balance.pendingIncoming.isZero() || !balance.pendingOutgoing.isZero()) { - HorizontalDivider() - PendingComposable(balance, onPendingClick) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LegacyBalanceWarningTooltip() { - 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 StatementRow( - 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 PendingComposable( - 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 BalancesComposablePreview() { - val balances = listOf( - BalanceItem( - scopeInfo = Global("CHF"), - available = Amount.fromJSONString("CHF:10.20"), - pendingIncoming = Amount.fromJSONString("CHF:1.20"), - pendingOutgoing = Amount.fromJSONString("CHF:0.40"), - 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.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 { - BalancesComposable( - innerPadding = PaddingValues(0.dp), - state = BalanceState.Success(balances, donauSummary), - devMode = false, - networkStatus = true, - onWithdrawMoneyClicked = {}, - onGetDemoMoneyClicked = {}, - onBalanceClicked = {}, - onPendingClicked = {}, - onStatementClicked = {}, - onShowDiscounts = {}, - onShowPasses = {}, - ) - } -} - -@Preview -@Composable -fun BalancesComposableErrorPreview() { - TalerSurface { - BalancesComposable ( - innerPadding = PaddingValues(0.dp), - state = BalanceState.Error(TalerErrorInfo - .makeCustomError("Balances could not be loaded")), - devMode = false, - networkStatus = false, - onWithdrawMoneyClicked = {}, - onGetDemoMoneyClicked = {}, - onBalanceClicked = {}, - onPendingClicked = {}, - onStatementClicked = {}, - onShowDiscounts = {}, - onShowPasses = {}, - ) - } -} - -@Preview -@Composable -fun BalancesComposableEmptyPreview() { - TalerSurface { - BalancesComposable ( - innerPadding = PaddingValues(0.dp), - state = BalanceState.Success(listOf(), listOf()), - devMode = false, - networkStatus = false, - onWithdrawMoneyClicked = {}, - onGetDemoMoneyClicked = {}, - onBalanceClicked = {}, - onPendingClicked = {}, - onStatementClicked = {}, - onShowDiscounts = {}, - onShowPasses = {}, - ) - } -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesListComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesListComposable.kt @@ -0,0 +1,313 @@ +/* + * 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.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.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +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.material3.Badge +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.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 + +@Composable +fun BalancesListComposable( + innerPadding: PaddingValues, + bottomFabSkip: Dp, + state: BalanceState, + devMode: Boolean, + networkStatus: Boolean, + onWithdrawMoneyClicked: () -> Unit, + onGetDemoMoneyClicked: () -> Unit, + onBalanceClicked: (balance: BalanceItem) -> Unit, + onStatementClicked: (host: String) -> Unit, +) { + 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 -> + BalanceRow( + balance, + onClick = { onBalanceClicked(balance) }, + ) + } + + if (state.donauSummary.isNotEmpty()) stickyHeader { + SectionHeader { Text(stringResource(R.string.balances_section_statements)) } + } + + items(state.donauSummary) { statement -> + StatementRow( + statement, + onClick = { onStatementClicked(statement.donauBaseUrl) }, + ) + } + + item { + Spacer(Modifier.height(bottomFabSkip)) + } + } + + else -> {} + } +} + +@Composable +fun SectionHeader( + 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 BalanceRow( + balance: BalanceItem, + onClick: () -> Unit, +) { + ElevatedCard(Modifier.cardPaddings()) { + Column { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() } + .padding(vertical = 6.dp), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { + Text( + balance.available.toString(), + style = MaterialTheme.typography.displaySmall, + ) + }, + 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) + ), + ) + + else -> {} + } + } + }, + ) + } + } +} + +@Composable +fun StatementRow( + summaryItem: DonauSummaryItem, + onClick: () -> Unit, +) { + ElevatedCard(Modifier.cardPaddings()) { + Column { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() } + .padding(vertical = 6.dp), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + 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, + ) + } + }, + ) + } + } +} + +@Preview +@Composable +fun BalancesListComposablePreview() { + 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, + ), + ) + + 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 { + BalancesListComposable( + innerPadding = PaddingValues(0.dp), + bottomFabSkip = 0.dp, + state = BalanceState.Success(balances, donauSummary), + devMode = false, + networkStatus = true, + onWithdrawMoneyClicked = {}, + onGetDemoMoneyClicked = {}, + onBalanceClicked = {}, + onStatementClicked = {}, + ) + } +} + +@Preview +@Composable +fun BalancesListComposableErrorPreview() { + TalerSurface { + BalancesListComposable ( + innerPadding = PaddingValues(0.dp), + bottomFabSkip = 0.dp, + state = BalanceState.Error(TalerErrorInfo + .makeCustomError("Balances could not be loaded")), + devMode = false, + networkStatus = false, + onWithdrawMoneyClicked = {}, + onGetDemoMoneyClicked = {}, + onBalanceClicked = {}, + onStatementClicked = {}, + ) + } +} + +@Preview +@Composable +fun BalancesListComposableEmptyPreview() { + TalerSurface { + BalancesListComposable ( + innerPadding = PaddingValues(0.dp), + bottomFabSkip = 0.dp, + state = BalanceState.Success(listOf(), listOf()), + devMode = false, + networkStatus = false, + onWithdrawMoneyClicked = {}, + onGetDemoMoneyClicked = {}, + onBalanceClicked = {}, + onStatementClicked = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesPagerComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesPagerComposable.kt @@ -0,0 +1,804 @@ +/* + * 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.balances + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.Center +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.EventRepeat +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Badge +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import net.taler.common.Amount +import net.taler.common.Timestamp +import net.taler.wallet.BottomInsetsSpacer +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.cleanExchange +import net.taler.wallet.compose.ActionButton +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.Material3MenuGroup +import net.taler.wallet.compose.Material3MenuItemData +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.cardPaddings +import net.taler.wallet.donau.DonauSummaryItem +import net.taler.wallet.transactions.DummyTransaction +import net.taler.wallet.transactions.Transaction +import net.taler.wallet.transactions.TransactionRow +import net.taler.wallet.transactions.TransactionsResult +import net.taler.wallet.ui.theme.errorContainerLight +import net.taler.wallet.ui.theme.onErrorContainerLight +import net.taler.wallet.ui.theme.onSuccessContainerLight +import net.taler.wallet.ui.theme.onSurfaceVariantLight +import net.taler.wallet.ui.theme.successContainerLight +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Whether the pager shows a balances list page (page 0) in front of the + * individual balances. With zero balances the list is still shown (it also + * carries statements and token sections); with a single balance the pager + * starts directly on that balance. + */ +internal fun showBalancesList(count: Int): Boolean = count != 1 + +/** + * Maps a selected [ScopeInfo] to the pager page that displays it. + * Page 0 is the balances list when [showBalancesList] is true, otherwise it + * is the first (and only) balance. A scope that is not part of [balances] + * falls back to page 0. + */ +internal fun scopeToPage( + balances: List<BalanceItem>, + scope: ScopeInfo?, + showBalancesList: Boolean, +): Int { + if (scope == null) return 0 + val index = balances.indexOfFirst { it.scopeInfo == scope } + if (index < 0) return 0 + return if (showBalancesList) index + 1 else index +} + +/** + * Maps a pager [page] to the balance it displays. Returns null for the + * balances list page (page 0 when [showBalancesList] is true) and for pages + * out of range. + */ +internal fun pageToBalance( + balances: List<BalanceItem>, + page: Int, + showBalancesList: Boolean, +): BalanceItem? = if (showBalancesList && page == 0) { + null +} else { + balances.getOrNull(if (showBalancesList) page - 1 else page) +} + +@Composable +fun balanceContainerColor() = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.primary +} else { + MaterialTheme.colorScheme.primaryContainer +} + +@Composable +fun balanceContentColor() = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.onPrimary +} else { + MaterialTheme.colorScheme.onPrimaryContainer +} + +@Composable +fun BalancesPagerComposable( + innerPadding: PaddingValues, + bottomFabSkip: Dp, + state: BalanceState, + selectedBalance: ScopeInfo?, + recentTxs: Map<ScopeInfo, StateFlow<TransactionsResult>?>, + devMode: Boolean, + networkStatus: Boolean, + onBalanceSelected: (BalanceItem?) -> Unit, + onWithdrawMoney: () -> Unit, + onGetDemoMoney: () -> Unit, + onShopping: (BalanceItem) -> Unit, + onSend: () -> Unit, + onRequest: () -> Unit, + onWithdraw: () -> Unit, + onDeposit: () -> Unit, + onTransactionClicked: (tx: Transaction) -> Unit, + onShowAllTransactions: (ScopeInfo) -> Unit, + onShowPendingIncoming: (ScopeInfo) -> Unit, + onShowPendingOutgoing: (ScopeInfo) -> Unit, + onShowDiscounts: (ScopeInfo?) -> Unit, + onShowPasses: (ScopeInfo?) -> Unit, +) { + if (state.showWelcome()) { + return EmptyBalancesComposable( + innerPadding = innerPadding, + networkStatus = networkStatus, + onWithdrawMoney, + onGetDemoMoney, + ) + } + + when (state) { + is BalanceState.Loading -> LoadingScreen() + is BalanceState.Error -> ErrorComposable( + state.error, + devMode = devMode, + modifier = Modifier.fillMaxSize() + ) + + is BalanceState.Success -> { + val balances = state.balances + val hasListPage = showBalancesList(balances.size) + val pageCount = balances.size + if (hasListPage) 1 else 0 + + // The selection (viewMode.selectedScope) is the single source of + // truth for the pager: the page it maps to is recomputed from the + // current balances on every composition, so balances changes + // (new exchange, removed scope) always land on the right page. + val selectedPage = scopeToPage(balances, selectedBalance, hasListPage) + val pagerState = rememberPagerState( + initialPage = selectedPage.coerceIn(0, pageCount - 1), + pageCount = { pageCount }, + ) + + LaunchedEffect(selectedPage) { + pagerState.animateScrollToPage(selectedPage.coerceIn(0, pageCount - 1)) + } + + LaunchedEffect(pagerState, state) { + snapshotFlow { pagerState.currentPage to pagerState.isScrollInProgress } + .collect { (page, inProgress) -> + if (!inProgress) onBalanceSelected(pageToBalance(balances, page, hasListPage)) + } + } + + Column(Modifier.padding(innerPadding)) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.Top, + ) { page -> + val balance = pageToBalance(balances, page, hasListPage) + if (balance == null) { + BalancesListComposable( + innerPadding = PaddingValues(0.dp), + bottomFabSkip = bottomFabSkip, + state = state, + devMode = devMode, + networkStatus = networkStatus, + onWithdrawMoneyClicked = onWithdrawMoney, + onGetDemoMoneyClicked = onGetDemoMoney, + onBalanceClicked = onBalanceSelected, + onStatementClicked = { onBalanceSelected(null) } + ) + } else { + val scope = balance.scopeInfo + val recentTx = recentTxs[scope]?.collectAsState(TransactionsResult.None) + BalancesPage( + pagerState = pagerState, + page = page, + showPaginator = pageCount > 1, + bottomFabSkip = bottomFabSkip, + balance = balance, + devMode = devMode, + disableActions = !networkStatus, + recentTxs = recentTx?.value, + onShoppingClicked = { onShopping(balance) }, + onTransactionClicked = onTransactionClicked, + onSend = onSend, + onRequest = onRequest, + onWithdraw = onWithdraw, + onDeposit = onDeposit, + onSelectBalance = { target -> + onBalanceSelected(pageToBalance(balances, target, hasListPage)) + }, + onShowAllTransactions = { onShowAllTransactions(scope) }, + onShowPendingIncoming = { onShowPendingIncoming(scope) }, + onShowPendingOutgoing = { onShowPendingOutgoing(scope) }, + onShowStatements = { onBalanceSelected(null) }, + onShowDiscounts = { onShowDiscounts(scope) }, + onShowPasses = { onShowPasses(scope) }, + ) + } + } + } + } + + else -> {} + } +} + +@Composable +fun BalancesPage( + balance: BalanceItem, + pagerState: PagerState, + page: Int, + showPaginator: Boolean = true, + bottomFabSkip: Dp, + devMode: Boolean, + disableActions: Boolean, + recentTxs: TransactionsResult?, + onShoppingClicked: () -> Unit, + onSend: () -> Unit, + onRequest: () -> Unit, + onWithdraw: () -> Unit, + onDeposit: () -> Unit, + onSelectBalance: (Int) -> Unit, + onTransactionClicked: (tx: Transaction) -> Unit, + onShowAllTransactions: () -> Unit, + onShowPendingIncoming: () -> Unit, + onShowPendingOutgoing: () -> Unit, + onShowStatements: () -> Unit, + onShowDiscounts: () -> Unit, + onShowPasses: () -> Unit, +) { + Column(Modifier.verticalScroll(rememberScrollState())) { + val spacing = 12.dp + BalanceHeaderCard( + balance, + onShowPendingIncoming, + onShowPendingOutgoing + ) + if (showPaginator) { + PageIndicator(pagerState = pagerState, page = page, onSelectBalance = onSelectBalance) + } + Spacer(Modifier.height(spacing)) + if (balance.shoppingUrls.isNotEmpty()) { + BalanceShoppingCard(balance, onShoppingClicked) + Spacer(Modifier.height(spacing)) + } + BalanceActionRow( + balance = balance, + disableActions = disableActions, + onSend = onSend, + onRequest = onRequest, + onWithdraw = onWithdraw, + onDeposit = onDeposit, + ) + Spacer(Modifier.height(spacing)) + BalanceRecentTransactions( + balance = balance, + devMode = devMode, + recentTxs = recentTxs, + onTransactionClicked = onTransactionClicked, + onShowAllTransactions = onShowAllTransactions, + ) + Spacer(Modifier.height(spacing * 2)) + BalanceAssetsRow( + onShowStatements = onShowStatements, + onShowDiscounts = onShowDiscounts, + onShowPasses = onShowPasses, + ) + Spacer(Modifier.height(bottomFabSkip)) + BottomInsetsSpacer() + } +} + +@Composable +fun PageIndicator( + pagerState: PagerState, + page: Int, + modifier: Modifier = Modifier, + onSelectBalance: (Int) -> Unit, +) { + // Continuous viewport position in page units: 0 = balances list, + // 1..n = individual balances. It also animates while paging. + val pagerPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction + + BoxWithConstraints(modifier) { + val pageWidth = constraints.maxWidth + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(vertical = 3.dp) + // Counter-translate so the row stays centered while pages slide. + .offset { + IntOffset( + x = (-pagerState.getOffsetDistanceInPages(page) * pageWidth).roundToInt(), + y = 0, + ) + } + // Fade out as this page leaves the viewport, cross-fading with + // the adjacent page's indicator (and out at the balances list). + .graphicsLayer { alpha = (1f - abs(page - pagerPosition) * 1.25f).coerceIn(0f, 1f) }, + horizontalArrangement = Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val inactive = MaterialTheme.colorScheme.outlineVariant + val active = MaterialTheme.colorScheme.outline + repeat(pagerState.pageCount) { iteration -> + // Ease the highlight between adjacent dots while paging. + val weight = (1f - abs(iteration - pagerPosition)).coerceIn(0f, 1f) + val color = lerp(inactive, active, weight) + val size = 12.dp + if (iteration > 0) Box( + modifier = Modifier + .padding(8.dp) + .clip(CircleShape) + .background(color) + .size(size) + .clickable { onSelectBalance(iteration) } + ) else { + OutlinedButton( + modifier = Modifier + .padding(end = 6.dp), + contentPadding = PaddingValues(0.dp), + onClick = { onSelectBalance(0) }, + ) { + Icon( + modifier = Modifier + .size(size * 2f), + imageVector = Icons.AutoMirrored.Default.List, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + contentDescription = stringResource(R.string.balances_title), + ) + } + } + } + } + } +} + +@Composable +fun BalanceHeaderCard( + balance: BalanceItem, + showPendingIncoming: () -> Unit, + showPendingOutgoing: () -> Unit, +) { + val onBackground = MaterialTheme.colorScheme.onBackground + ElevatedCard(Modifier + .cardPaddings() + .fillMaxWidth() + .dropShadow(CardDefaults.elevatedShape, block = { + radius = 5f + offset = Offset(0f, 6f) + alpha = 0.16f + color = onBackground + }), + elevation = CardDefaults.elevatedCardElevation( + defaultElevation = 0.dp, + ), + colors = CardDefaults.elevatedCardColors( + containerColor = balanceContainerColor(), + contentColor = balanceContentColor(), + ), + ) { + Column { + Box(Modifier + .align(Alignment.End) + .padding(16.dp) + .padding(bottom = 10.dp)) { + ProvideTextStyle(MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic, + color = onSurfaceVariantLight, + )) { + when (val info = balance.scopeInfo) { + is Exchange -> Text(cleanExchange(info.url)) + is Auditor -> Text(cleanExchange(info.url)) + else -> {} + } + } + } + + Text(balance.available.toString(), + modifier = Modifier + .padding(16.dp) + .defaultMinSize(minHeight = 60.dp) + .fillMaxWidth(), + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold, + color = balanceContentColor(), + ), + ) + + HorizontalDivider() + + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Text( + text = "Pending:", + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + style = MaterialTheme.typography.labelLarge, + ) + AssistChip( + modifier = Modifier + .padding(start = 16.dp), + label = { Text(balance.pendingIncoming.toString(showSymbol = false)) }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = "Pending incoming") }, + colors = AssistChipDefaults.assistChipColors( + containerColor = successContainerLight, + labelColor = onSuccessContainerLight, + leadingIconContentColor = onSuccessContainerLight, + ), + shape = CircleShape, + onClick = showPendingIncoming, + ) + + AssistChip( + modifier = Modifier + .padding(start = 16.dp), + label = { Text(balance.pendingIncoming.toString(showSymbol = false)) }, + leadingIcon = { Icon(Icons.Default.Remove, contentDescription = "Pending outgoing") }, + colors = AssistChipDefaults.assistChipColors( + containerColor = errorContainerLight, + labelColor = onErrorContainerLight, + leadingIconContentColor = onErrorContainerLight, + ), + shape = CircleShape, + onClick = showPendingOutgoing, + ) + } + } + } +} + +@Composable +fun BalanceShoppingCard( + balance: BalanceItem, + onClick: () -> Unit, +) { + Box(Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 9.dp) + ) { + Material3MenuGroup(items = buildList { + add( + Material3MenuItemData( + title = { Text(stringResource( + R.string.exchange_shopping_label, + balance.currency, + )) }, + icon = { Icon( + Icons.Default.LocationOn, + contentDescription = null, + ) }, + onClick = onClick, + cardColors = CardDefaults.cardColors( + containerColor = balanceContainerColor(), + contentColor = balanceContentColor(), + ) + ) + ) + }) + } +} + +@Composable +fun BalanceRecentTransactions( + balance: BalanceItem, + devMode: Boolean, + recentTxs: TransactionsResult?, + onTransactionClicked: (tx: Transaction) -> Unit, + onShowAllTransactions: () -> Unit, +) { + val placeholderHeight = 320.dp // FIXME: calculate this dynamically + OutlinedCard(Modifier.cardPaddings()) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.balances_recent_transactions), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelLarge) + + Badge( + modifier = Modifier.clickable { onShowAllTransactions() }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Row( + modifier = Modifier.padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.all)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Icon( + Icons.AutoMirrored.Default.ArrowForward, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + } + } + } + + when (val res = recentTxs) { + is TransactionsResult.Success -> { + if (res.transactions.isEmpty()) { + EmptyComposable( + message = stringResource(R.string.transactions_empty), + modifier = Modifier.height(placeholderHeight), + ) + } else res.transactions.forEach { tx -> + TransactionRow( + tx = tx, + spec = balance.available.spec, + isSelected = false, + selectionMode = false, + onTransactionClick = { onTransactionClicked(tx) }, + onTransactionSelect = {}, + ) + } + } + + is TransactionsResult.Error -> ErrorComposable( + modifier = Modifier + .defaultMinSize(minHeight = placeholderHeight), + error = res.error, + devMode = devMode, + ) + + null, is TransactionsResult.None -> LoadingScreen( + modifier = Modifier + .fillMaxWidth() + .height(placeholderHeight), + ) + } + } + } +} + +@Composable +fun BalanceActionRow( + balance: BalanceItem, + disableActions: Boolean, + onSend: () -> Unit, + onRequest: () -> Unit, + onWithdraw: () -> Unit, + onDeposit: () -> Unit, +) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + ) { + ActionButton( + icon = { Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = null) }, + label = { Text(stringResource(R.string.transactions_send_funds)) }, + shape = CircleShape, + enabled = !disableActions && !balance.disablePeerPayments, + modifier = Modifier.weight(1f), + onClick = onSend, + contentColor = balanceContentColor(), + containerColor = balanceContainerColor(), + ) + + ActionButton( + icon = { Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) }, + label = { Text(stringResource(R.string.transactions_receive_funds)) }, + shape = CircleShape, + enabled = !disableActions && !balance.disablePeerPayments, + modifier = Modifier.weight(1f), + onClick = onRequest, + contentColor = balanceContentColor(), + containerColor = balanceContainerColor(), + ) + + ActionButton( + icon = { Icon(Icons.Default.ArrowDownward, contentDescription = null) }, + label = { Text(stringResource(R.string.withdraw_button_label)) }, + shape = CircleShape, + modifier = Modifier.weight(1f), + onClick = onWithdraw, + enabled = !disableActions, + contentColor = balanceContentColor(), + containerColor = balanceContainerColor(), + ) + + ActionButton( + icon = { Icon(Icons.Default.ArrowUpward, contentDescription = null) }, + label = { Text(stringResource(R.string.send_deposit_button_label)) }, + shape = CircleShape, + modifier = Modifier.weight(1f), + onClick = onDeposit, + enabled = !disableActions, + contentColor = balanceContentColor(), + containerColor = balanceContainerColor(), + ) + } +} + +@Composable +fun BalanceAssetsRow( + onShowStatements: () -> Unit, + onShowDiscounts: () -> Unit, + onShowPasses: () -> Unit, +) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + ) { + ActionButton( + icon = { Icon(painterResource(R.drawable.ic_discounts), contentDescription = null) }, + label = { Text(stringResource(R.string.discounts_title)) }, + onClick = onShowDiscounts, + modifier = Modifier.weight(1f).padding(horizontal = 6.dp), + contentColor = balanceContentColor(), + containerColor = balanceContainerColor(), + ) + + ActionButton( + icon = { Icon(Icons.Default.EventRepeat, contentDescription = null) }, + label = { Text(stringResource(R.string.passes_title)) }, + onClick = onShowPasses, + modifier = Modifier.weight(1f).padding(horizontal = 6.dp), + contentColor = balanceContentColor(), + containerColor = balanceContainerColor(), + ) + + ActionButton( + icon = { Icon(painterResource(R.drawable.ic_donau), contentDescription = null) }, + label = { Text(stringResource(R.string.balances_section_statements)) }, + onClick = onShowStatements, + modifier = Modifier.weight(1f).padding(horizontal = 6.dp), + contentColor = balanceContentColor(), + containerColor = balanceContainerColor(), + ) + } +} + +@Preview +@Composable +fun BalancesPagerComposablePreview() { + val balances = listOf( + BalanceItem( + scopeInfo = 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, + ), + ) + + val recentTxs: Map<ScopeInfo, StateFlow<TransactionsResult>> = mapOf( + Exchange("KUDOS", "https://exchange.demo.taler.net") to MutableStateFlow<TransactionsResult>(TransactionsResult.Success( + listOf( + DummyTransaction("txn:1234", Timestamp.now(), TalerErrorInfo.makeCustomError("no error")), + DummyTransaction("txn:2345", Timestamp.now(), TalerErrorInfo.makeCustomError("no error")), + DummyTransaction("txn:3456", Timestamp.now(), TalerErrorInfo.makeCustomError("no error")), + DummyTransaction("txn:4567", Timestamp.now(), TalerErrorInfo.makeCustomError("no error")), + ))) + ) + + 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 { + BalancesPagerComposable( + innerPadding = PaddingValues(0.dp), + bottomFabSkip = 0.dp, + selectedBalance = Exchange("KUDOS", "https://exchange.demo.taler.net"), + state = BalanceState.Success(balances, donauSummary), + recentTxs = recentTxs, + devMode = false, + networkStatus = true, + onBalanceSelected = {}, + onWithdrawMoney = {}, + onGetDemoMoney = {}, + onShopping = {}, + onSend = {}, + onRequest = {}, + onWithdraw = {}, + onDeposit = {}, + onTransactionClicked = {}, + onShowAllTransactions = {}, + onShowPendingIncoming = {}, + onShowPendingOutgoing = {}, + onShowDiscounts = {}, + onShowPasses = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/compose/ActionButton.kt b/wallet/src/main/java/net/taler/wallet/compose/ActionButton.kt @@ -0,0 +1,68 @@ +/* + * 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.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ActionButton( + icon: @Composable () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + shape: Shape = RoundedCornerShape(12.dp), + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + labelColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + enabled: Boolean = true, +) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + FloatingActionButton( + onClick = if (enabled) onClick else {{}}, + shape = shape, + containerColor = if (enabled) containerColor else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (enabled) contentColor else MaterialTheme.colorScheme.onSurfaceVariant, + elevation = FloatingActionButtonDefaults.elevation(0.dp), + content = { + icon() + }, + ) + + Spacer(modifier = Modifier.height(6.dp)) + + ProvideTextStyle( + MaterialTheme.typography.labelLarge + .copy(color = if (enabled) labelColor else Color.Gray, textAlign = TextAlign.Center) + ) { + label() + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt b/wallet/src/main/java/net/taler/wallet/contacts/ContactScreen.kt @@ -67,7 +67,6 @@ import net.taler.wallet.WalletDestination import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.TalerSurface import net.taler.wallet.main.MainViewModel -import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -203,7 +202,7 @@ fun ContactScreen( TextButton(onClick = { model.mailboxManager.sendMessage(contact, sendUri!!) scopeInfo?.let { - model.showTransactions(it, Nonfinal) + model.displayScope(it) } onNavigate(WalletDestination.Main, true) contactToConfirm = null diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingScreen.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeShoppingScreen.kt @@ -52,7 +52,6 @@ import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.getAttrColor import net.taler.wallet.launchInAppBrowser import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.ViewMode @Composable fun ExchangeShoppingScreen( @@ -60,7 +59,7 @@ fun ExchangeShoppingScreen( onNavigateBack: () -> Unit, ) { val viewMode by model.viewMode.collectAsStateLifecycleAware() - val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope + val selectedScope = viewMode.selectedScope val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) val selectedBalance = remember(balanceState, selectedScope) { diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt @@ -16,66 +16,54 @@ package net.taler.wallet.main -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent 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.BarChart -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.QrCodeScanner 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.OutlinedTextField +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.TextButton -import androidx.compose.material3.rememberModalBottomSheetState +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.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.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import kotlinx.coroutines.flow.first +import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch -import net.taler.common.Event 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.BalancesComposable -import net.taler.wallet.balances.ScopeInfo +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.settings.SettingsScreen -import net.taler.wallet.transactions.Transaction -import net.taler.wallet.transactions.TransactionMajorState -import net.taler.wallet.transactions.TransactionPeerPullDebit -import net.taler.wallet.transactions.TransactionPeerPushCredit -import net.taler.wallet.transactions.TransactionState -import net.taler.wallet.transactions.TransactionStateFilter.Nonfinal -import net.taler.wallet.transactions.TransactionsComposable -import net.taler.wallet.transactions.TransactionsResult - -enum class MainTab { ASSETS, SETTINGS } +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 @@ -83,33 +71,23 @@ fun MainScreen( model: MainViewModel, onNavigate: NavigateCallback, onFulfillPayment: (url: String) -> Unit, - onShowError: (TalerErrorInfo) -> Unit, ) { - var tab by rememberSaveable { mutableStateOf(MainTab.ASSETS) } - var showSheet by rememberSaveable { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - 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 online by model.networkManager.networkStatus.observeAsState(false) val networkStatus by model.networkManager.networkStatus.observeAsState(false) val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) val viewMode by model.viewMode.collectAsStateLifecycleAware() - val databaseMigrationState by model.databaseMigrationState.collectAsStateLifecycleAware() val devMode by model.devMode.observeAsState(false) - val txResult by remember(viewMode) { - val v = viewMode as? ViewMode.Transactions - model.transactionManager.transactionsFlow(v?.selectedScope, stateFilter = v?.stateFilter) - }.collectAsStateLifecycleAware() - val actionButtonUsed by remember { - model.settingsManager.getActionButtonUsed(context) - }.collectAsStateLifecycleAware(true) + 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) { @@ -128,332 +106,114 @@ fun MainScreen( } } - val tabTitle = when (tab) { - MainTab.ASSETS -> when (viewMode) { - is ViewMode.Transactions -> if (selectionMode) { - stringResource(R.string.selection_count, selectedItems.size) - } else stringResource(R.string.transactions_title) - - ViewMode.Assets -> if (!balanceState.showWelcome()) { - stringResource(R.string.balances_title) - } else null - } - - MainTab.SETTINGS -> stringResource(R.string.menu_settings) - } + val tooltipState = rememberTooltipState() + var fabHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current GlobalScaffold( model = model, modifier = Modifier.fillMaxSize(), - title = tabTitle?.let { { Text(it) } }, - navigationIcon = if (selectionMode) { - { - IconButton(onClick = { - selectionMode = false - selectedItems.clear() - }) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = null, - ) - } - } - } else null, + title = {}, + snackbarHost = { SnackbarHost(snackbarHostState) }, 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), - ) - } + IconButton(onClick = { + onNavigate(WalletDestination.Settings, false) + }) { + Icon( + Icons.Default.Settings, + contentDescription = stringResource(R.string.menu_settings)) } }, - bottomBar = { - NavigationBar { - NavigationBarItem( - icon = { Icon(Icons.Default.BarChart, contentDescription = null) }, - label = { Text(stringResource(R.string.balances_title)) }, - selected = tab == MainTab.ASSETS, + 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 = { - tab = MainTab.ASSETS - if (viewMode !is ViewMode.Assets) - 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 == MainTab.SETTINGS, - onClick = { tab = MainTab.SETTINGS }, - ) + ) { + Icon( + Icons.Default.QrCodeScanner, + modifier = Modifier.size(34.dp), + contentDescription = stringResource(R.string.button_scan_qr_code_label), + ) + } } }, - snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> LaunchedEffect(Unit) { - model.balanceManager.loadAssets(model.viewMode.value is ViewMode.Assets) - } - - LaunchedEffect(Unit) { - val viewMode = model.settingsManager.getViewMode(context).first() - model.setViewMode(viewMode) - } - - LaunchedEffect(viewMode) { - model.settingsManager.saveViewMode(context, viewMode) - } - - val migrateCompleteMsg = stringResource(R.string.settings_db_migrate_complete) - LaunchedEffect(databaseMigrationState) { - if (databaseMigrationState == DatabaseMigrationState.Complete) { - snackbarHostState.showSnackbar(migrateCompleteMsg) - model.acknowledgeDatabaseMigrationComplete() - } + val initialLoad = model.balanceManager.state.value is BalanceState.None + model.balanceManager.loadAssets(initialLoad) } - BackHandler(selectionMode || (tab == MainTab.ASSETS && viewMode !is ViewMode.Assets)) { - if (selectionMode) { - selectionMode = false - selectedItems.clear() - } else { - model.showAssets() - } - } - - 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)) - } + BalancesPagerComposable( + innerPadding = innerPadding, + bottomFabSkip = fabHeight + 24.dp, + state = balanceState, + selectedBalance = viewMode.selectedScope, + recentTxs = txResults, + devMode = devMode, + networkStatus = networkStatus, + onBalanceSelected = { + model.displayScope(it?.scopeInfo) }, - dismissButton = { - TextButton(onClick = { - showDeleteDialog = false - }) { - Text(stringResource(R.string.cancel)) - } + onWithdrawMoney = { + onNavigate( + WalletDestination.HandleUri("taler://withdraw-exchange/exchange.taler-ops.ch/"), + true, + ) }, - ) - - when (tab) { - MainTab.ASSETS -> AnimatedContent( - targetState = viewMode, - modifier = Modifier.fillMaxSize(), - label = "MainViewMode", - ) { targetViewMode -> - when (targetViewMode) { - is ViewMode.Assets -> BalancesComposable( - 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 = { - model.showTransactions(it.scopeInfo) - }, - onPendingClicked = { - model.showTransactions(it.scopeInfo, Nonfinal) - }, - onStatementClicked = { - onNavigate(WalletDestination.DonauStatement(it), false) - }, - onShowDiscounts = { - onNavigate(WalletDestination.DiscountList, false) - }, - onShowPasses = { - onNavigate(WalletDestination.PassList, false) - }, - ) - - is ViewMode.Transactions -> { - val balance = remember(balanceState, targetViewMode) { - (balanceState as? BalanceState.Success)?.balances?.find { - it.scopeInfo == targetViewMode.selectedScope - } - } - - if (balance != null) TransactionsComposable( - innerPadding = innerPadding, - viewMode = targetViewMode, - balance = balance, - txResult = txResult, - selectionMode = selectionMode, - selectedItems = selectedItems, - onTransactionClick = { tx -> - onTransactionClicked(tx, model, onNavigate) - }, - onShowBalancesClicked = { - model.showAssets() - }, - onToggleSelection = { txId -> - if (selectedItems.contains(txId)) { - selectedItems.remove(txId) - if (selectedItems.isEmpty()) selectionMode = false - } else { - selectionMode = true - selectedItems.add(txId) - } - }, - ) - } + onGetDemoMoney = { + model.withdrawManager.withdrawTestBalance() + scope.launch { + snackbarHostState.showSnackbar(testWithdrawalMessage) } - } - - MainTab.SETTINGS -> SettingsScreen( - model = model, - innerPadding = innerPadding, - snackbarHostState = snackbarHostState, - onNavigate = onNavigate, - onShowError = onShowError, - ) - } - - val selectedScope = (viewMode as? ViewMode.Transactions)?.selectedScope - - val selectedBalance = remember(balanceState, selectedScope) { - val balances = (balanceState as? BalanceState.Success)?.balances - selectedScope?.let { - balances?.find { it.scopeInfo == selectedScope } - } - } - - val disableActions = remember(balanceState, online) { - val hasSpendableBalance = (balanceState as? BalanceState.Success)?.balances - ?.any { it.scopeInfo !is ScopeInfo.ExchangeLegacyKeys } ?: false - !online || !hasSpendableBalance - } - - 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 + onShopping = { balance -> 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) + 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 = { - 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) + 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) }, - onShoppingDiscovery = { - showSheet = false - model.settingsManager.saveActionButtonUsed(context) - val shoppingUrls = selectedBalance?.shoppingUrls ?: emptyList() - if (shoppingUrls.size == 1) { - onFulfillPayment(shoppingUrls[0]) - } else onNavigate(WalletDestination.ExchangeShopping, false) - } ) } } - -private fun onTransactionClicked( - tx: Transaction, - model: MainViewModel, - onNavigate: NavigateCallback, -) { - val showTxDetails = { - model.transactionManager.selectTransaction(tx).invokeOnCompletion { - onNavigate(tx.detailPageNav, false) - } - } - - when (tx.txState) { - // unfinished transactions (dialog) - TransactionState(TransactionMajorState.Dialog) -> when (tx) { -// is TransactionPayment -> { -// model.paymentManager.preparePay(tx.transactionId) {} -// onNavigate(WalletDestination.PromptPayment, true) -// } - - is TransactionPeerPushCredit -> { - model.peerManager.preparePeerPushCredit(transactionId = tx.transactionId) - onNavigate(WalletDestination.PromptPushPayment, true) - } - - is TransactionPeerPullDebit -> { - model.peerManager.preparePeerPullDebit(transactionId = tx.transactionId) - onNavigate(WalletDestination.PromptPullPayment, true) - } - - else -> showTxDetails() - } - - else -> showTxDetails() - } -} diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch import net.taler.common.Amount @@ -53,7 +54,6 @@ import net.taler.wallet.peer.PeerManager import net.taler.wallet.refund.RefundManager import net.taler.wallet.settings.SettingsManager import net.taler.wallet.transactions.TransactionManager -import net.taler.wallet.transactions.TransactionStateFilter import net.taler.wallet.withdraw.WithdrawManager import net.taler.wallet.BuildConfig import net.taler.wallet.NetworkManager @@ -155,7 +155,7 @@ class MainViewModel( private val mShowMessageEvent = MutableLiveData<Event<NotifyMessage>>() val showMessageEvent: LiveData<Event<NotifyMessage>> = mShowMessageEvent - private val mViewMode = MutableStateFlow<ViewMode>(ViewMode.Assets) + private val mViewMode = MutableStateFlow(ViewMode()) val viewMode: StateFlow<ViewMode> = mViewMode private val mInitError = MutableStateFlow<TalerErrorInfo?>(null) @@ -207,18 +207,8 @@ class MainViewModel( is NotificationPayload.TransactionStateTransition -> { paymentManager.onTransactionStateTransition(payload) - viewModelScope.launch(Dispatchers.Main) { - payload.transactionId?.let { id -> - // update currently selected transaction - transactionManager.updateTransactionIfSelected(id) - // update currently selected transaction list - transactionManager.getTransactionById(id)?.let { tx -> - val v = viewMode.value - if (v is ViewMode.Transactions && v.selectedScope in tx.scopes) { - transactionManager.loadTransactions(v.selectedScope) - } - } - } + payload.transactionId?.let { id -> + transactionManager.onTransactionStateTransition(id) } } @@ -256,37 +246,39 @@ class MainViewModel( mAuthenticated.value = true } - fun setViewMode(v: ViewMode?) = viewModelScope.launch { - mViewMode.value = when(v) { - null -> ViewMode.Assets - is ViewMode.Transactions -> v.copy( - // fill-in currency spec from DB - selectedSpec = exchangeManager.getCurrencySpecification(v.selectedScope), - ) - else -> v + fun setViewMode(v: ViewMode?) { + val newMode = v ?: ViewMode() + val current = mViewMode.value + // Guard against redundant updates: recompositions from the pager, the + // balances list and other screens all funnel through here, and each + // change is persisted immediately. + if (current.selectedScope == newMode.selectedScope) return + mViewMode.value = newMode + if (newMode.selectedScope != null) { + viewModelScope.launch { + val spec = exchangeManager.getCurrencySpecification(newMode.selectedScope) + mViewMode.getAndUpdate { vm -> + if (vm.selectedScope == newMode.selectedScope) { + vm.copy(selectedSpec = spec) + } else { + vm + } + } + } } + settingsManager.saveViewMode(newMode) } - fun selectScope(scopeInfo: ScopeInfo?) { + fun displayScope(scopeInfo: ScopeInfo?) { if (scopeInfo != null) { - setViewMode(ViewMode.Transactions(scopeInfo)) + setViewMode(ViewMode(scopeInfo)) } else { - setViewMode(ViewMode.Assets) + setViewMode(ViewMode()) } } fun showAssets() { - if (viewMode.value != ViewMode.Assets) { - selectScope(null) - } - } - - /** - * Navigates to the given scope info's transaction list, when [MainScreen] is shown. - */ - @UiThread - fun showTransactions(scopeInfo: ScopeInfo, stateFilter: TransactionStateFilter? = null) { - mViewMode.value = ViewMode.Transactions(scopeInfo, stateFilter = stateFilter) + displayScope(null) } // FIXME: get rid of this ugliness! use wallet-core! @@ -436,6 +428,13 @@ class MainViewModel( fun showInfoMessage(content: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) { mShowMessageEvent.value = Event(NotifyMessage(content, actionLabel, onAction)) } + + init { + viewModelScope.launch { + val viewMode = settingsManager.getViewMode().first() + if (mViewMode.value.selectedScope == null) setViewMode(viewMode) + } + } } sealed class AmountResult { diff --git a/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt b/wallet/src/main/java/net/taler/wallet/main/TalerActionButton.kt @@ -1,252 +0,0 @@ -/* - * 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/main/ViewMode.kt b/wallet/src/main/java/net/taler/wallet/main/ViewMode.kt @@ -17,49 +17,23 @@ package net.taler.wallet.main import net.taler.common.CurrencySpecification -import net.taler.wallet.PrefsViewMode -import net.taler.wallet.ViewModeType +import net.taler.wallet.PrefsViewMode2 import net.taler.wallet.balances.ScopeInfo -import net.taler.wallet.transactions.TransactionStateFilter -sealed class ViewMode { - abstract fun toPrefs(): PrefsViewMode - - data object Assets: ViewMode() { - override fun toPrefs(): PrefsViewMode = PrefsViewMode - .newBuilder() - .setType(ViewModeType.ASSETS) - .build() - } - - data class Transactions( - val selectedScope: ScopeInfo, - val selectedSpec: CurrencySpecification? = null, - val searchQuery: String? = null, - val stateFilter: TransactionStateFilter? = null, - ): ViewMode() { - override fun toPrefs(): PrefsViewMode = PrefsViewMode - .newBuilder() - .setType(ViewModeType.TRANSACTIONS) - .setSelectedScope(selectedScope.toPrefs()) - .apply { - if (this@Transactions.searchQuery != null) - setSearchQuery(this@Transactions.searchQuery) - if (this@Transactions.stateFilter != null) - setStateFilter(this@Transactions.stateFilter.toPrefs()) - }.build() - } +data class ViewMode( + val selectedScope: ScopeInfo? = null, + val selectedSpec: CurrencySpecification? = null, +) { + fun toPrefs(): PrefsViewMode2 = PrefsViewMode2 + .newBuilder() + .apply { + if (this@ViewMode.selectedScope != null) + setSelectedScope(this@ViewMode.selectedScope.toPrefs()) + }.build() companion object { - fun fromPrefs(prefs: PrefsViewMode): ViewMode = when (prefs.type) { - ViewModeType.ASSETS, ViewModeType.UNRECOGNIZED -> Assets - - ViewModeType.TRANSACTIONS -> Transactions( - // TODO: better null handling - selectedScope = ScopeInfo.fromPrefs(prefs.selectedScope)!!, - searchQuery = prefs.searchQuery, - stateFilter = TransactionStateFilter.fromPrefs(prefs.stateFilter), - ) - } + fun fromPrefs(prefs: PrefsViewMode2) = ViewMode( + selectedScope = prefs.selectedScope?.let { ScopeInfo.fromPrefs(it) }, + ) } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullScreen.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullScreen.kt @@ -35,7 +35,6 @@ import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.ViewMode @Composable fun OutgoingPullScreen( @@ -90,12 +89,12 @@ fun OutgoingPullScreen( onTosAccept = { exchangeBaseUrl -> onNavigate(WalletDestination.ReviewExchangeTOS(exchangeBaseUrl), false) }, - defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, + defaultScope = remember { viewMode.selectedScope }, scopes = balanceManager.getScopes(true), devMode = devMode, getCurrencySpec = exchangeManager::getSpecForScopeInfo, checkPeerPullCredit = { amount, loading -> - model.selectScope(amount.scope) + model.displayScope(amount.scope) peerManager.checkPeerPullCredit( amount.amount, scopeInfo = amount.scope, diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushScreen.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushScreen.kt @@ -35,7 +35,6 @@ import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.ViewMode @Composable fun OutgoingPushScreen( @@ -84,12 +83,12 @@ fun OutgoingPushScreen( OutgoingPushComposable( modifier = Modifier.padding(paddingValues), state = state, - defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, + defaultScope = remember { viewMode.selectedScope }, scopes = balanceManager.getScopes(true), devMode = devMode, getCurrencySpec = exchangeManager::getSpecForScopeInfo, getFees = { - model.selectScope(it.scope) + model.displayScope(it.scope) peerManager.checkPeerPushFees(it.amount, restrictScope = it.scope) }, onSend = { amount, summary, hours -> diff --git a/wallet/src/main/java/net/taler/wallet/scan/ScanQrScreen.kt b/wallet/src/main/java/net/taler/wallet/scan/ScanQrScreen.kt @@ -257,6 +257,34 @@ private fun QrScannerTab( ) { Row( modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.ZoomOut, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = zoomLevel, + onValueChange = { value -> + zoomLevel = value + analyzer?.setLinearZoom(value) + }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + ) + Icon( + imageVector = Icons.Default.ZoomIn, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.Top, ) { @@ -313,50 +341,6 @@ private fun QrScannerTab( ) } } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = "%.1f×".format(1f + zoomLevel * 9f), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.ZoomOut, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Slider( - value = zoomLevel, - onValueChange = { value -> - zoomLevel = value - analyzer?.setLinearZoom(value) - }, - modifier = Modifier - .weight(1f) - .padding(horizontal = 4.dp), - ) - Icon( - imageVector = Icons.Default.ZoomIn, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } } } } diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt @@ -50,36 +50,31 @@ class SettingsManager( private val mPerformanceTable = MutableStateFlow<PerformanceTable?>(null) val performanceTable: StateFlow<PerformanceTable?> = mPerformanceTable - fun getViewMode(c: Context) = c.userPreferencesDataStore.data.map { prefs -> - if (prefs.hasViewMode()) { - ViewMode.fromPrefs(prefs.viewMode) + fun getViewMode() = context.userPreferencesDataStore.data.map { prefs -> + if (prefs.hasViewMode2()) { + ViewMode.fromPrefs(prefs.viewMode2) } else { null } } - fun saveViewMode(c: Context, viewMode: ViewMode?) = scope.launch { - c.userPreferencesDataStore.updateData { current -> + fun saveViewMode(viewMode: ViewMode?) = scope.launch { + context.userPreferencesDataStore.updateData { current -> + val builder = current.toBuilder() + // Drop the deprecated pre-viewMode2 field if it lingers. + .clearViewMode() if (viewMode != null) { - current.toBuilder() - .setViewMode(viewMode.toPrefs()) + builder + .setViewMode2(viewMode.toPrefs()) .build() } else { - current.toBuilder() - .clearViewMode() + builder + .clearViewMode2() .build() } } } - 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() 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,7 +38,6 @@ 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 @@ -60,6 +59,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -80,12 +80,14 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch +import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.BuildConfig 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.backend.WalletDatabaseBackend +import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.main.MainViewModel import net.taler.wallet.withdraw.TestWithdrawStatus @@ -93,12 +95,12 @@ import net.taler.wallet.withdraw.TestWithdrawStatus @Composable fun SettingsScreen( model: MainViewModel, - innerPadding: PaddingValues, - snackbarHostState: SnackbarHostState, onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, onShowError: (TalerErrorInfo) -> Unit, ) { val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } 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) @@ -148,240 +150,255 @@ fun SettingsScreen( } } - 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_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_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() - + 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_list_add_dev), - icon = Icons.Default.DomainAdd, - onClick = { - model.exchangeManager.addDevExchanges() - onNavigate(WalletDestination.ExchangeList, false) - } + 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_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) - } + 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_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") } + 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_stats), - summary = stringResource(R.string.settings_stats_summary), - icon = ImageVector.vectorResource(R.drawable.ic_stats), - onClick = { onNavigate(WalletDestination.PerformanceStats, 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) + } + } ) - SettingsItem( - title = "Contacts", - summary = "Contacts", - icon = Icons.Default.Person, - onClick = { onNavigate(WalletDestination.Contacts(), 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) + } ) - SettingsItem( - title = "Mailbox", - summary = "P2P Messaging", - icon = ImageVector.vectorResource(R.drawable.ic_adjust), - onClick = { onNavigate(WalletDestination.Mailbox, false) } - ) + if (devModeEnabled) { + HorizontalDivider() - 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.exchange_list_add_dev), + icon = Icons.Default.DomainAdd, + onClick = { + model.exchangeManager.addDevExchanges() + onNavigate(WalletDestination.ExchangeList, 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_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_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_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_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_stats), + summary = stringResource(R.string.settings_stats_summary), + icon = ImageVector.vectorResource(R.drawable.ic_stats), + onClick = { onNavigate(WalletDestination.PerformanceStats, false) } + ) - 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 = "Contacts", + summary = "Contacts", + icon = Icons.Default.Person, + onClick = { onNavigate(WalletDestination.Contacts(), false) } + ) - 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 = "Mailbox", + summary = "P2P Messaging", + icon = ImageVector.vectorResource(R.drawable.ic_adjust), + onClick = { onNavigate(WalletDestination.Mailbox, false) } + ) - SettingsItem( - title = stringResource(R.string.settings_version_core), - summary = "$walletVersion ($walletVersionHash)", - icon = ImageVector.vectorResource(R.drawable.ic_adjust), - ) + SettingsItem( + title = stringResource(R.string.alias_registration_title), + summary = "Taldir Registration", + icon = Icons.Default.Person, + onClick = { onNavigate(WalletDestination.AliasRegistration, false) } + ) - if (exchangeVersion != null) { SettingsItem( - title = stringResource(R.string.settings_version_protocol_exchange), - summary = exchangeVersion, - icon = ImageVector.vectorResource(R.drawable.ic_account_balance), + 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 } ) - } - if (merchantVersion != null) { SettingsItem( - title = stringResource(R.string.settings_version_protocol_merchant), - summary = merchantVersion, - icon = ImageVector.vectorResource(R.drawable.ic_store_mall), + 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_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_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, + ) + + 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_protocol_exchange), + summary = exchangeVersion, + icon = ImageVector.vectorResource(R.drawable.ic_account_balance), + ) } - ) - 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() + if (merchantVersion != null) { + SettingsItem( + title = stringResource(R.string.settings_version_protocol_merchant), + summary = merchantVersion, + icon = ImageVector.vectorResource(R.drawable.ic_store_mall), + ) } - ) - 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") + 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) } } - }) { - Text(stringResource(R.string.wallet_export_database_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { pendingDbExport = null }) { - Text(stringResource(R.string.cancel)) - } + .setPositiveButton(R.string.cancel) { _, _ -> + scope.launch { snackbarHostState.showSnackbar(resetCanceledMessage) } + } + .show() } ) + + 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() } } } diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -17,8 +17,8 @@ package net.taler.wallet.transactions import android.util.Log -import androidx.annotation.UiThread import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,7 +29,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonPrimitive -import net.taler.wallet.PrefsStateFilter import net.taler.wallet.main.TAG import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo @@ -54,30 +53,38 @@ enum class TransactionStateFilter { @SerialName("done") Done; - - fun toPrefs(): PrefsStateFilter = when(this) { - Done -> PrefsStateFilter.DONE - Final -> PrefsStateFilter.FINAL - Nonfinal -> PrefsStateFilter.NONFINAL - } - - companion object { - fun fromPrefs(prefs: PrefsStateFilter): TransactionStateFilter? = when(prefs) { - PrefsStateFilter.DONE -> Done - PrefsStateFilter.FINAL -> Final - PrefsStateFilter.NONFINAL -> Nonfinal - PrefsStateFilter.NONE, - PrefsStateFilter.UNRECOGNIZED -> null - } - } } class TransactionManager( private val api: WalletBackendApi, private val scope: CoroutineScope, ) { - private val allTransactions = HashMap<ScopeInfo, List<Transaction>>() + private companion object { + /** + * Number of most recent transactions shown on the balances screen. + * Negative values mean "the last N transactions" in wallet-core. + */ + const val RECENT_LIMIT = -4 + } + + private data class FullListParams( + val searchQuery: String?, + val stateFilter: TransactionStateFilter?, + ) + + private val recentTransactions = HashMap<ScopeInfo, List<Transaction>>() + private val fullTransactions = HashMap<ScopeInfo, List<Transaction>>() + + private val mRecentTransactions = HashMap<ScopeInfo, MutableStateFlow<TransactionsResult>>() private val mTransactions = HashMap<ScopeInfo, MutableStateFlow<TransactionsResult>>() + + /** In-flight loads per scope, so a newer request cancels an older one. */ + private val recentLoadJobs = HashMap<ScopeInfo, Job>() + private val fullLoadJobs = HashMap<ScopeInfo, Job>() + + /** Parameters of the full list requested per scope, used to refresh it later. */ + private val fullListParams = HashMap<ScopeInfo, FullListParams>() + private val mSelectedTransaction = MutableStateFlow<Transaction?>(null) private val mSelectedTransactionId = MutableStateFlow<String?>(null) private val selectionMutex = Mutex() @@ -86,53 +93,97 @@ class TransactionManager( val selectedTransaction = mSelectedTransaction.asStateFlow() val selectedTransferOption = mSelectedTransferOption.asStateFlow() - // This function must be called ONLY when scopeInfo / searchQuery change! - // Use remember() {} in Compose to prevent multiple calls during recomposition + /** + * Streams the [RECENT_LIMIT] most recent transactions for [scopeInfo], as + * shown on the balances screen. + */ + fun recentTransactionsFlow(scopeInfo: ScopeInfo): StateFlow<TransactionsResult> { + val flow = mRecentTransactions.getOrPut(scopeInfo) { + MutableStateFlow(TransactionsResult.None) + } + loadRecentTransactions(scopeInfo) + return flow.asStateFlow() + } + + /** + * Streams the full transaction list for [scopeInfo], as shown on the + * transactions screen. [searchQuery] and [stateFilter] narrow the request. + */ fun transactionsFlow( - scopeInfo: ScopeInfo? = null, + scopeInfo: ScopeInfo?, searchQuery: String? = null, stateFilter: TransactionStateFilter? = null, ): StateFlow<TransactionsResult> { - return if (scopeInfo != null) { - loadTransactions(scopeInfo, searchQuery, stateFilter) - mTransactions[scopeInfo]?.asStateFlow() - ?: MutableStateFlow(TransactionsResult.None) - } else { + if (scopeInfo == null) return MutableStateFlow(TransactionsResult.None) + fullListParams[scopeInfo] = FullListParams(searchQuery, stateFilter) + val flow = mTransactions.getOrPut(scopeInfo) { MutableStateFlow(TransactionsResult.None) } + loadFullTransactions(scopeInfo, searchQuery, stateFilter) + return flow.asStateFlow() } - fun loadTransactions( - scopeInfo: ScopeInfo? = null, - searchQuery: String? = null, - stateFilter: TransactionStateFilter? = null, + private fun loadRecentTransactions(scopeInfo: ScopeInfo) { + val flow = mRecentTransactions[scopeInfo] ?: return + recentLoadJobs[scopeInfo]?.cancel() + recentLoadJobs[scopeInfo] = scope.launch { + // show cached data immediately... + recentTransactions[scopeInfo]?.let { + flow.value = TransactionsResult.Success(it) + } + // ...then fetch the latest and update the cache + val result = getTransactions(scopeInfo, searchQuery = null, limit = RECENT_LIMIT) + if (result is TransactionsResult.Success) { + recentTransactions[scopeInfo] = result.transactions + } + flow.value = result + } + } + + private fun loadFullTransactions( + scopeInfo: ScopeInfo, + searchQuery: String?, + stateFilter: TransactionStateFilter?, ) { - Log.d(TAG, "loadTransactions($scopeInfo, $searchQuery, $stateFilter)") - val scopes = scopeInfo?.let { listOf(it) } ?: mTransactions.keys.toList() - if (scopes.isEmpty()) return - - scopes.forEach { s -> - // initialize key with empty state flow - if (mTransactions[s] == null) { - mTransactions[s] = MutableStateFlow(TransactionsResult.None) + val flow = mTransactions[scopeInfo] ?: return + // Only the unfiltered list is cached, since filters can change. + val cacheable = searchQuery == null && stateFilter == null + fullLoadJobs[scopeInfo]?.cancel() + fullLoadJobs[scopeInfo] = scope.launch { + if (cacheable) fullTransactions[scopeInfo]?.let { + flow.value = TransactionsResult.Success(it) } + val result = getTransactions(scopeInfo, searchQuery, stateFilter) + if (result is TransactionsResult.Success && cacheable) { + fullTransactions[scopeInfo] = result.transactions + } + flow.value = result + } + } - scope.launch { - // return cached transactions if available - if (searchQuery == null) allTransactions[s]?.let { txs -> - mTransactions[s]?.value = TransactionsResult.Success(txs) - } + /** + * Handles a transaction-state-transition notification for [transactionId] by + * refreshing the selected transaction (if it is being viewed) and both the + * recent and full transaction lists of the affected scopes. Lists that have + * not been requested yet are left untouched. + */ + fun onTransactionStateTransition(transactionId: String) = scope.launch { + val transaction = getTransactionById(transactionId) - // ...then fetch new ones - val res = getTransactions(s, searchQuery, filterByState = stateFilter) - if (res is TransactionsResult.Success) { - allTransactions[s] = res.transactions + if (transaction != null && mSelectedTransactionId.value == transactionId) { + selectionMutex.withLock { + if (mSelectedTransactionId.value == transactionId) { + mSelectedTransaction.value = transaction } - - // ...and then emit them when available - mTransactions[s]?.value = res } } + + if (transaction == null) return@launch + transaction.scopes.forEach { scopeInfo -> + loadRecentTransactions(scopeInfo) + val params = fullListParams[scopeInfo] + loadFullTransactions(scopeInfo, params?.searchQuery, params?.stateFilter) + } } private suspend fun getTransactions( @@ -211,12 +262,6 @@ class TransactionManager( mSelectedTransferOption.value = option } - @UiThread - fun updateTransactionIfSelected(id: String) = scope.launch { - if (mSelectedTransactionId.value != id) return@launch - refreshSelectedTransaction() - } - fun selectTransaction(tx: Transaction?) = scope.launch { if (tx == null) { mSelectedTransactionId.value = null @@ -235,7 +280,7 @@ class TransactionManager( onError(it) }.onSuccess { // re-load transactions as our list is stale otherwise - loadTransactions() + refreshAllTransactions() } } @@ -246,7 +291,7 @@ class TransactionManager( }.onError { onError(it) }.onSuccess { - loadTransactions() + refreshAllTransactions() } } @@ -262,7 +307,7 @@ class TransactionManager( onError(it) }.onSuccess { onSuccess() - loadTransactions() + refreshAllTransactions() } } @@ -273,7 +318,7 @@ class TransactionManager( }.onError { onError(it) }.onSuccess { - loadTransactions() + refreshAllTransactions() } } @@ -284,7 +329,7 @@ class TransactionManager( }.onError { onError(it) }.onSuccess { - loadTransactions() + refreshAllTransactions() } } @@ -295,13 +340,13 @@ class TransactionManager( }.onError { onError(it) }.onSuccess { - loadTransactions() + refreshAllTransactions() } } fun deleteTransactions(transactionIds: List<String>, onError: (it: TalerErrorInfo) -> Unit) = scope.launch { - allTransactions.values.flatten().filter { transaction -> + fullTransactions.values.flatten().filter { transaction -> transaction.transactionId in transactionIds && Delete in transaction.txActions }.forEach { toBeDeletedTx -> api.request<Unit>("deleteTransaction") { @@ -310,6 +355,15 @@ class TransactionManager( onError(it) } } - loadTransactions() + refreshAllTransactions() + } + + /** Reloads every transaction list that has been requested so far. */ + private fun refreshAllTransactions() { + mRecentTransactions.keys.toList().forEach { loadRecentTransactions(it) } + mTransactions.keys.toList().forEach { scopeInfo -> + val params = fullListParams[scopeInfo] + loadFullTransactions(scopeInfo, params?.searchQuery, params?.stateFilter) } -} -\ 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 @@ -18,7 +18,6 @@ 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 @@ -35,16 +34,15 @@ 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.ElevatedCard 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 @@ -59,7 +57,6 @@ 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 @@ -67,19 +64,17 @@ 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.BottomInsetsSpacer 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.LegacyBalanceWarningTooltip 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.main.ViewMode import net.taler.wallet.transactions.AmountType.Negative import net.taler.wallet.transactions.AmountType.Neutral import net.taler.wallet.transactions.AmountType.Positive @@ -98,7 +93,6 @@ 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 @@ -107,11 +101,10 @@ import net.taler.wallet.ui.theme.TalerTheme @Composable fun TransactionsComposable( innerPadding: PaddingValues, - viewMode: ViewMode.Transactions, balance: BalanceItem, txResult: TransactionsResult, + stateFilter: TransactionStateFilter?, onTransactionClick: (tx: Transaction) -> Unit, - onShowBalancesClicked: () -> Unit, selectionMode: Boolean, selectedItems: MutableList<String>, onToggleSelection: (String) -> Unit, @@ -124,14 +117,11 @@ fun TransactionsComposable( contentPadding = innerPadding, ) { item { - TransactionsHeader( - balance = balance, - onShowBalancesClicked = onShowBalancesClicked, - ) + TransactionsHeader(balance) } - when (viewMode.stateFilter) { - Nonfinal -> item { + when (stateFilter) { + TransactionStateFilter.Nonfinal -> item { Banner(Modifier.padding(bottom = 6.dp)) { Text( modifier = Modifier @@ -184,6 +174,10 @@ fun TransactionsComposable( } } } + + item { + BottomInsetsSpacer() + } } } } @@ -214,20 +208,19 @@ fun ErrorTransactionsComposable(error: TalerErrorInfo) { @Composable fun TransactionsHeader( balance: BalanceItem, - onShowBalancesClicked: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - OutlinedCard( + ElevatedCard( Modifier .weight(1f) .cardPaddings() - .clickable { onShowBalancesClicked() }, ) { ListItem( modifier = Modifier.animateContentSize(), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), headlineContent = { Text( getHeaderCurrency(balance, balance.available.spec), @@ -246,24 +239,8 @@ fun TransactionsHeader( .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) { - LegacyBalanceWarningTooltip() - } - Icon(Icons.Default.ArrowDropDown, contentDescription = null) - } - } ) } @@ -281,11 +258,6 @@ fun TransactionsHeader( balance.available.toString(showSymbol = false), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - textDecoration = if (balance.scopeInfo is ExchangeLegacyKeys) { - TextDecoration.LineThrough - } else { - TextDecoration.None - }, ) } } @@ -506,10 +478,9 @@ fun TransactionsComposableDonePreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - viewMode = ViewMode.Transactions(previewBalance.scopeInfo), + stateFilter = null, txResult = Success(transactions), onTransactionClick = {}, - onShowBalancesClicked = {}, selectionMode = false, selectedItems = mutableListOf(), onToggleSelection = {}, @@ -542,10 +513,9 @@ fun TransactionsComposablePendingPreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(transactions), + stateFilter = TransactionStateFilter.Nonfinal, onTransactionClick = {}, - onShowBalancesClicked = {}, selectionMode = false, selectedItems = mutableListOf(), onToggleSelection = {}, @@ -560,10 +530,9 @@ fun TransactionsComposableEmptyPreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(listOf()), + stateFilter = null, onTransactionClick = {}, - onShowBalancesClicked = {}, selectionMode = false, selectedItems = mutableListOf(), onToggleSelection = {}, @@ -578,10 +547,9 @@ fun TransactionsComposableLoadingPreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = None, + stateFilter = null, onTransactionClick = {}, - onShowBalancesClicked = {}, selectionMode = false, selectedItems = mutableListOf(), onToggleSelection = {}, diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsScreen.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsScreen.kt @@ -0,0 +1,205 @@ +/* + * 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.transactions + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.livedata.observeAsState +import androidx.compose.runtime.mutableStateListOf +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.stringResource +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.compose.GlobalScaffold +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel + +@Composable +fun TransactionsScreen( + model: MainViewModel, + filter: TransactionStateFilter? = null, + onShowError: (TalerErrorInfo) -> Unit, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + var selectionMode by remember { mutableStateOf(false) } + val selectedItems = remember { mutableStateListOf<String>() } + var showDeleteDialog by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + val viewMode by model.viewMode.collectAsStateLifecycleAware() + val stateFilter by remember { mutableStateOf(filter) } + val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) + val txResult by remember(viewMode, stateFilter) { + model.transactionManager.transactionsFlow(viewMode.selectedScope, stateFilter = stateFilter) + }.collectAsStateLifecycleAware() + + val balance = remember(balanceState, viewMode) { + (balanceState as? BalanceState.Success)?.balances?.find { + it.scopeInfo == viewMode.selectedScope + } + } + + 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)) + } + }, + ) + + GlobalScaffold( + model = model, + modifier = Modifier.fillMaxSize(), + title = { + Text( + text = if (selectionMode) { + stringResource(R.string.selection_count, selectedItems.size) + } else stringResource(R.string.transactions_title), + ) + }, + 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) }, + onNavigateBack = onNavigateBack, + ) { innerPadding -> + LaunchedEffect(Unit) { + model.balanceManager.loadAssets(true) + } + + if (balance != null) TransactionsComposable( + innerPadding = innerPadding, + balance = balance, + txResult = txResult, + stateFilter = stateFilter, + selectionMode = selectionMode, + selectedItems = selectedItems, + onTransactionClick = { tx -> + onTransactionClicked(tx, model, onNavigate) + }, + onToggleSelection = { txId -> + if (selectedItems.contains(txId)) { + selectedItems.remove(txId) + if (selectedItems.isEmpty()) selectionMode = false + } else { + selectionMode = true + selectedItems.add(txId) + } + }, + ) + } +} + +fun onTransactionClicked( + tx: Transaction, + model: MainViewModel, + onNavigate: NavigateCallback, +) { + val showTxDetails = { + model.transactionManager.selectTransaction(tx).invokeOnCompletion { + onNavigate(tx.detailPageNav, false) + } + } + + when (tx.txState) { + // unfinished transactions (dialog) + TransactionState(TransactionMajorState.Dialog) -> when (tx) { + is TransactionPeerPushCredit -> { + model.peerManager.preparePeerPushCredit(transactionId = tx.transactionId) + onNavigate(WalletDestination.PromptPushPayment, true) + } + + is TransactionPeerPullDebit -> { + model.peerManager.preparePeerPullDebit(transactionId = tx.transactionId) + onNavigate(WalletDestination.PromptPullPayment, true) + } + + else -> showTxDetails() + } + + else -> showTxDetails() + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawScreen.kt @@ -34,7 +34,6 @@ import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.exchanges.SelectExchangeComposable import net.taler.wallet.main.MainViewModel -import net.taler.wallet.main.ViewMode import androidx.compose.material3.Text import androidx.compose.ui.res.stringResource import androidx.compose.ui.Modifier @@ -62,9 +61,7 @@ fun PromptWithdrawScreen( var showExchangeSelect by remember { mutableStateOf(false) } - val selectedScope = remember(viewMode) { - (viewMode as? ViewMode.Transactions)?.selectedScope - } + val selectedScope = viewMode.selectedScope val scopes by balanceManager.balances .map { bl -> bl.filter { it.scopeInfo !is ExchangeLegacyKeys }.map { it.scopeInfo } } @@ -107,7 +104,7 @@ fun PromptWithdrawScreen( WithdrawStatus.Status.AlreadyConfirmed -> { status.transactionId?.let { txId -> if (transactionManager.selectTransaction(txId)) { - status.amountInfo?.scopeInfo?.let { s -> model.selectScope(s) } + status.amountInfo?.scopeInfo?.let { s -> model.displayScope(s) } onNavigate(WalletDestination.TransactionWithdrawal, true) } else { onNavigateBack() @@ -168,7 +165,7 @@ fun PromptWithdrawScreen( } }, onConfirm = { age -> - status.selectedScope?.let { model.selectScope(it) } + status.selectedScope?.let { model.displayScope(it) } withdrawManager.acceptWithdrawal(age) }, onReset = { diff --git a/wallet/src/main/proto/user_prefs.proto b/wallet/src/main/proto/user_prefs.proto @@ -24,11 +24,13 @@ enum PrefsStateFilter { DONE = 3; } +// DEPRECATED enum ViewModeType { ASSETS = 0; TRANSACTIONS = 1; } +// DEPRECATED message PrefsViewMode { ViewModeType type = 1; @@ -38,10 +40,15 @@ message PrefsViewMode { optional PrefsStateFilter stateFilter = 4; } +message PrefsViewMode2 { + optional PrefsScopeInfo selectedScope = 1; +} + message UserPreferences { optional PrefsScopeInfo selectedScope = 1; optional bool actionButtonUsed = 2; optional bool devModeEnabled = 3; optional bool biometricLockEnabled = 4; optional PrefsViewMode viewMode = 5; + optional PrefsViewMode2 viewMode2 = 6; } \ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -46,6 +46,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <!-- General --> + <string name="all">All</string> <string name="actions">Actions</string> <string name="button_back">Go back</string> <string name="button_scan_qr_code_label">Scan QR code</string> @@ -161,6 +162,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="balances_legacy_unavailable">Legacy balance unavailable</string> <!-- must be minus sign! (− U+2212) --> <string name="balances_outbound_amount">−%1$s outgoing</string> + <string name="balances_recent_transactions">Recent transactions</string> <string name="balances_section_statements">Donation statements</string> <string name="balances_section_tokens">Other assets</string> <string name="balances_title">Balances</string>