taler-android

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

commit 9fd748ed7dcc61f1de6ba9ccc431bf13043f2909
parent 74b8cfbd77751ae1ae1c1fa08934c31accaad5cb
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 22 Oct 2025 16:16:47 +0200

[wallet] load DONAU summaries from getBalances

Diffstat:
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt | 73++++++++++++++++++++++---------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt | 39++++++++++++++++++++-------------------
Awallet/src/main/java/net/taler/wallet/compose/EmptyComposable.kt | 45+++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/donau/DonauManager.kt | 34++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/donau/DonauResponses.kt | 30++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/donau/DonauStatementComposable.kt | 4++--
Mwallet/src/main/java/net/taler/wallet/donau/DonauStatementFragment.kt | 71+++++++++++++++++++++++++++++++----------------------------------------
Mwallet/src/main/res/values/strings.xml | 1+
9 files changed, 186 insertions(+), 113 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -123,7 +123,7 @@ class MainViewModel( val settingsManager: SettingsManager = SettingsManager(app.applicationContext, api, viewModelScope, balanceManager) val accountManager: AccountManager = AccountManager(api, viewModelScope) val depositManager: DepositManager = DepositManager(api, viewModelScope, balanceManager) - val donauManager: DonauManager = DonauManager(api, viewModelScope) + val donauManager: DonauManager = DonauManager(api, viewModelScope, exchangeManager) private val mAuthenticated = MutableStateFlow(false) val authenticated: StateFlow<Boolean> = mAuthenticated diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -18,7 +18,6 @@ package net.taler.wallet.balances import android.util.Log import androidx.annotation.UiThread -import androidx.compose.ui.util.fastDistinctBy import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.distinctUntilChanged @@ -31,14 +30,14 @@ import net.taler.common.CurrencySpecification import net.taler.wallet.TAG import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi -import net.taler.wallet.donau.DonauStatement -import net.taler.wallet.donau.GetDonauStatementsResponse +import net.taler.wallet.donau.DonauSummaryItem import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeManager @Serializable data class BalanceResponse( - val balances: List<BalanceItem> + val balances: List<BalanceItem>, + val donauSummary: List<DonauSummaryItem>? = null, ) @Serializable @@ -53,7 +52,7 @@ sealed class BalanceState { data class Success( val balances: List<BalanceItem>, - val statements: List<DonauStatement>, + val donauSummary: List<DonauSummaryItem>, ): BalanceState() data class Error( @@ -75,65 +74,37 @@ class BalanceManager( fun loadAssets(loading: Boolean = false) = scope.launch { if (loading) mState.postValue(BalanceState.Loading) - loadBalances()?.let { balancesList -> - mState.postValue(BalanceState.Success( - balances = balancesList, - statements = emptyList(), - )) - - // TODO: load lists together when getDonationStatements stops relying on network - loadDonauStatements()?.let { statementsList -> - mState.postValue(BalanceState.Success( - balances = balancesList, - statements = statementsList, - )) - } - } - } - - private suspend fun loadBalances(): List<BalanceItem>? { - var res: List<BalanceItem>? = null api.request("getBalances", BalanceResponse.serializer()) .onError { Log.e(TAG, "Error retrieving balances: $it") mState.postValue(BalanceState.Error(it)) - }.onSuccess { - it.balances.map { balance -> + }.onSuccess { res -> + val balances = res.balances.map { balance -> val spec = runBlocking { exchangeManager .getCurrencySpecification(balance.scopeInfo) } balance.copy( available = balance.available.withSpec(spec), - pendingIncoming = balance.pendingIncoming.withSpec(spec), - pendingOutgoing = balance.pendingOutgoing.withSpec(spec), + pendingIncoming = balance.available.withSpec(spec), + pendingOutgoing = balance.available.withSpec(spec), ) - }.let { balances -> - res = balances - mBalances.postValue(balances) } - } - return res - } - private suspend fun loadDonauStatements(): List<DonauStatement>? { - var list: List<DonauStatement>? = null - api.request("getDonauStatements", GetDonauStatementsResponse.serializer()) - .onError { - Log.e(TAG, "Error retrieving donau statements: $it") - // TODO: throw error when getDonationStatements stop relying on network - // mState.postValue(BalanceState.Error(it)) - }.onSuccess { res -> - // only return last year for each authority - list = res.statements.map { statement -> + val donauSummary = res.donauSummary?.map { item -> val spec = runBlocking { exchangeManager - .getSpecForCurrency(statement.total.currency) } - statement.copy(total = statement.total.withSpec(spec)) - }.sortedByDescending { - it.year - }.fastDistinctBy { - it.host - } + .getSpecForCurrency(item.amountReceiptsAvailable.currency) } + item.copy( + amountReceiptsAvailable = item.amountReceiptsAvailable.withSpec(spec), + amountReceiptsSubmitted = item.amountReceiptsSubmitted.withSpec(spec), + amountStatement = item.amountStatement?.withSpec(spec), + ) + } ?: emptyList() + + mBalances.postValue(balances) + mState.postValue(BalanceState.Success( + balances = balances, + donauSummary = donauSummary, + )) } - return list } @UiThread diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt b/wallet/src/main/java/net/taler/wallet/balances/BalancesComposable.kt @@ -61,7 +61,7 @@ import net.taler.wallet.cleanExchange import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.cardPaddings -import net.taler.wallet.donau.DonauStatement +import net.taler.wallet.donau.DonauSummaryItem import net.taler.wallet.withdraw.WithdrawalError // TODO: rename to AssetsComposable @@ -80,7 +80,7 @@ fun BalancesComposable( is BalanceState.Error -> WithdrawalError(state.error) is BalanceState.Success -> if ( state.balances.isNotEmpty() - || state.statements.isNotEmpty()) { + || state.donauSummary.isNotEmpty()) { LazyColumn( Modifier .consumeWindowInsets(innerPadding) @@ -99,16 +99,14 @@ fun BalancesComposable( ) } - if (state.statements.isNotEmpty()) stickyHeader { + if (state.donauSummary.isNotEmpty()) stickyHeader { SectionHeader { Text(stringResource(R.string.assets_section_statements)) } } - items(state.statements, key = { it.year }) { statement -> + items(state.donauSummary, key = { it.year }) { statement -> StatementRow( statement, - onClick = { statement.host?.let { - onStatementClicked(it) - } }, + onClick = { onStatementClicked(statement.donauBaseUrl) }, ) } } @@ -189,7 +187,7 @@ fun BalanceRow( @Composable fun StatementRow( - statement: DonauStatement, + summaryItem: DonauSummaryItem, onClick: () -> Unit, ) { OutlinedCard(Modifier.cardPaddings()) { @@ -201,13 +199,16 @@ fun StatementRow( .padding(vertical = 6.dp), headlineContent = { Text( - statement.total.toString(), + summaryItem.amountReceiptsAvailable.toString(), style = MaterialTheme.typography.displaySmall, ) }, overlineContent = { ProvideTextStyle(value = MaterialTheme.typography.bodySmall) { - Text(stringResource(R.string.balance_scope_exchange, statement.legalDomain)) + // FIXME: security risk, faking donauBaseUrl! + Text(stringResource(R.string.balance_scope_exchange, + summaryItem.legalDomain + ?: cleanExchange(summaryItem.donauBaseUrl))) } }, trailingContent = { @@ -216,7 +217,7 @@ fun StatementRow( contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) { Text( - "${statement.year}", + "${summaryItem.year}", modifier = Modifier.padding(6.dp), style = MaterialTheme.typography.titleMedium, ) @@ -323,21 +324,21 @@ fun BalancesComposablePreview() { ), ) - val statements = listOf( - DonauStatement( - total = Amount.fromJSONString("KUDOS:0.1"), - year = 2025, + val donauSummary = listOf( + DonauSummaryItem( + donauBaseUrl = "https://donau.test.taler.net/", legalDomain = "Gnuland", - uri = "donau://donau.test.taler.net/...", - donationStatementSig = "1234567890", - donauPub = "1234567890" + year = 2025, + amountReceiptsSubmitted = Amount.fromJSONString("KUDOS:10"), + amountReceiptsAvailable = Amount.fromJSONString("KUDOS:10"), + ) ) TalerSurface { BalancesComposable( innerPadding = PaddingValues(0.dp), - state = BalanceState.Success(balances, statements), + state = BalanceState.Success(balances, donauSummary), onGetDemoMoneyClicked = {}, onBalanceClicked = {}, onPendingClicked = {}, diff --git a/wallet/src/main/java/net/taler/wallet/compose/EmptyComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/EmptyComposable.kt @@ -0,0 +1,44 @@ +/* + * 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.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.taler.wallet.R + +@Composable +fun EmptyComposable( + message: String = stringResource(R.string.empty), +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(message) + } +} + +@Preview +@Composable +fun EmptyComposablePreview() { + TalerSurface { + EmptyComposable() + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauManager.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauManager.kt @@ -21,17 +21,24 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import net.taler.wallet.TAG import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.exchanges.ExchangeManager class DonauManager( private val api: WalletBackendApi, private val scope: CoroutineScope, + private val exchangeManager: ExchangeManager, ) { private val mDonauStatus = MutableStateFlow<GetDonauStatus>(GetDonauStatus.None) val donauStatus = mDonauStatus.asStateFlow() + private val mDonauStatementsStatus = + MutableStateFlow<GetDonauStatementsStatus>(GetDonauStatementsStatus.None) + val donauStatementsStatus = mDonauStatementsStatus.asStateFlow() + fun setDonau( info: DonauInfo, onSuccess: () -> Unit, @@ -59,4 +66,31 @@ class DonauManager( mDonauStatus.value = GetDonauStatus.Success(res.currentDonauInfo) } } + + fun getDonauStatements( + donauBaseUrl: String? = null, + ) = scope.launch { + mDonauStatementsStatus.value = GetDonauStatementsStatus.Loading + api.request("getDonauStatements", GetDonauStatementsResponse.serializer()) { + donauBaseUrl?.let { put("donauBaseUrl", it) } + this + }.onError { error -> + Log.e(TAG, "Error retrieving donau statements: $error") + mDonauStatementsStatus.value = GetDonauStatementsStatus.Error(error) + }.onSuccess { res -> + val statements = res.statements.map { statement -> + val spec = runBlocking { exchangeManager + .getSpecForCurrency(statement.total.currency) } + statement.copy( + total = statement.total.withSpec(spec) + ) + }.sortedByDescending { + it.year + } + + mDonauStatementsStatus.value = GetDonauStatementsStatus.Success( + statements = statements, + ) + } + } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauResponses.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauResponses.kt @@ -55,6 +55,36 @@ data class DonauStatement( } @Serializable +data class DonauSummaryItem( + /** Base URL of the donau service. */ + val donauBaseUrl: String, + + /** Legal domain of the donau service (if available). */ + val legalDomain: String? = null, + + /** Year of the donation(s). */ + val year: Int, + + /** + * Sum of donation receipts we received from merchants in the + * applicable year. + */ + val amountReceiptsAvailable: Amount, + + /** + * Sum of donation receipts that were already submitted + * to the donau in the applicable year. + */ + val amountReceiptsSubmitted: Amount, + + /** + * Amount of the latest available statement. Missing if no statement + * was requested yet. + */ + val amountStatement: Amount? = null, +) + +@Serializable data class GetDonauStatementsResponse( val statements: List<DonauStatement>, ) diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauStatementComposable.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauStatementComposable.kt @@ -62,7 +62,8 @@ fun DonauStatementComposable( .verticalScroll(rememberScrollState()), horizontalAlignment = CenterHorizontally, ) { - ScrollableTabRow( + // only show tab row if more than one year + if (statements.size > 1) ScrollableTabRow( selectedTabIndex = selectedIndex, edgePadding = 8.dp, ) { @@ -91,7 +92,6 @@ fun DonauStatementComposable( shareAsQrCode = true, ) - TransactionAmountComposable( label = stringResource(id = R.string.amount_total), amount = statement.total, diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauStatementFragment.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauStatementFragment.kt @@ -23,27 +23,25 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import kotlinx.coroutines.flow.MutableStateFlow -import net.taler.common.showError import net.taler.wallet.MainViewModel import net.taler.wallet.R -import net.taler.wallet.balances.BalanceState +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.ErrorComposable import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware -import net.taler.wallet.showError class DonauStatementFragment: Fragment() { private val model: MainViewModel by activityViewModels() private lateinit var host: String - private val mStatements = MutableStateFlow<List<DonauStatement>>(emptyList()) override fun onCreateView( inflater: LayoutInflater, @@ -58,49 +56,42 @@ class DonauStatementFragment: Fragment() { setContent { TalerSurface { - val statements by mStatements.collectAsStateLifecycleAware() - if (statements.isEmpty()) { - LoadingScreen() - } else { - var selectedIndex by rememberSaveable { mutableIntStateOf(0) } - DonauStatementComposable(statements, selectedIndex) { index -> - selectedIndex = index - } + val status by model.donauManager.donauStatementsStatus.collectAsStateLifecycleAware() + val devMode by model.devMode.observeAsState() + when (val s = status) { + is GetDonauStatementsStatus.None, + is GetDonauStatementsStatus.Loading -> LoadingScreen() + + is GetDonauStatementsStatus.Error -> ErrorComposable( + error = s.error, + devMode = devMode == true, + ) + + is GetDonauStatementsStatus.Success -> if (s.statements.isEmpty()) { + EmptyComposable() + } else { + var selectedIndex by rememberSaveable { mutableIntStateOf(0) } + DonauStatementComposable( + statements = s.statements, + selectedIndex = selectedIndex, + ) { index -> + selectedIndex = index + } - LaunchedEffect(selectedIndex) { - supportActionBar?.title = - getString( + LaunchedEffect(selectedIndex) { + supportActionBar?.title = getString( R.string.donau_statement_title_year, - statements[selectedIndex].year, + s.statements[selectedIndex].year, ) + } } } } } } - override fun onStart() { - super.onStart() - model.balanceManager.state.observe(viewLifecycleOwner) { state -> - when (state) { - is BalanceState.Error -> { - if (model.devMode.value == true) { - showError(state.error) - } else { - showError(state.error.userFacingMsg) - } - } - - is BalanceState.Success -> { - state.statements.filter { - it.host == host - }.let { - mStatements.value = it - } - } - - else -> {} - } - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + model.donauManager.getDonauStatements(host) } } \ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -60,6 +60,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="currency_url">%1$s (%2$s)</string> <string name="currency_via">via</string> <string name="edit">Edit</string> + <string name="empty">No items were found</string> <string name="enter_uri">Enter taler:// URI</string> <string name="enter_uri_label">Enter URI</string> <string name="error">Error</string>