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:
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>