commit aa45abb2425dea36e7662a48fab87bf999b0117d parent 9552dbd3c95fda02a6a7fecf6a731fc1fd0ea092 Author: Iván Ávalos <avalos@disroot.org> Date: Thu, 9 Oct 2025 12:58:38 +0200 [wallet] add support for DONAU receipts Diffstat:
33 files changed, 1565 insertions(+), 282 deletions(-)
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt @@ -233,45 +233,37 @@ data class ContractChoice( ) @Serializable -enum class ContractInputType { - @SerialName("token") - Token, -} - -@Serializable +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("type") sealed class ContractInput { - abstract val type: ContractInputType - @Serializable @SerialName("token") data class Token( @SerialName("token_family_slug") val tokenFamilySlug: String, val count: Int = 1, - ): ContractInput() { - override val type: ContractInputType = ContractInputType.Token - } -} - -@Serializable -enum class ContractOutputType { - @SerialName("token") - Token, + ): ContractInput() } @Serializable +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("type") sealed class ContractOutput { - abstract val type: ContractOutputType - @Serializable @SerialName("token") data class Token( @SerialName("token_family_slug") val tokenFamilySlug: String, val count: Int = 1, - ): ContractOutput() { - override val type: ContractOutputType = ContractOutputType.Token - } + ): ContractOutput() + + @Serializable + @SerialName("tax-receipt") + data class TaxReceipt( + val amount: Amount, + @SerialName("donau_urls") + val donauUrls: List<String>, + ): ContractOutput() } @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. + * (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 @@ -148,8 +148,8 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - model.transactionManager.selectedScope.collect { tx -> - model.settingsManager.saveSelectedScope(this@MainActivity, tx) + model.viewMode.collect { tx -> + model.settingsManager.saveViewMode(this@MainActivity, tx) } } } @@ -337,6 +337,7 @@ class MainActivity : AppCompatActivity(), OnPreferenceStartFragmentCallback { ): Boolean { when (pref.key) { "pref_exchanges" -> nav.navigate(R.id.nav_settings_exchanges) + "pref_donau" -> nav.navigate(R.id.nav_settings_donau) } return true } diff --git a/wallet/src/main/java/net/taler/wallet/MainFragment.kt b/wallet/src/main/java/net/taler/wallet/MainFragment.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2024 Taler Systems S.A. + * (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 @@ -70,6 +70,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.compose.AndroidFragment @@ -81,13 +82,13 @@ import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import net.taler.wallet.balances.BalanceState -import net.taler.wallet.balances.BalancesComposable -import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.DemandAttention import net.taler.wallet.compose.GridMenu import net.taler.wallet.compose.GridMenuItem import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainComposable +import net.taler.wallet.main.ViewMode import net.taler.wallet.settings.SettingsFragment import net.taler.wallet.transactions.Transaction import net.taler.wallet.transactions.TransactionMajorState @@ -98,7 +99,7 @@ import kotlin.math.roundToInt class MainFragment: Fragment() { - enum class Tab { BALANCES, SETTINGS } + enum class Tab { ASSETS, SETTINGS } private val model: MainViewModel by activityViewModels() @@ -110,7 +111,7 @@ class MainFragment: Fragment() { ): View = ComposeView(requireContext()).apply { setContent { TalerSurface { - var tab by rememberSaveable { mutableStateOf(Tab.BALANCES) } + var tab by rememberSaveable { mutableStateOf(Tab.ASSETS) } var showSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState() @@ -119,10 +120,11 @@ class MainFragment: Fragment() { val context = LocalContext.current val online by model.networkManager.networkStatus.observeAsState(false) val balanceState by model.balanceManager.state.observeAsState(BalanceState.None) - val selectedScope by model.transactionManager.selectedScope.collectAsStateLifecycleAware() - val txStateFilter by model.transactionManager.stateFilter.collectAsStateLifecycleAware() - val txResult by remember(selectedScope, txStateFilter) { model.transactionManager.transactionsFlow(selectedScope, stateFilter = txStateFilter) }.collectAsStateLifecycleAware() - val selectedSpec = remember(selectedScope) { selectedScope?.let { model.exchangeManager.getSpecForScopeInfo(it) } } + val viewMode by model.viewMode.collectAsStateLifecycleAware() + 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) Scaffold( @@ -130,13 +132,12 @@ class MainFragment: Fragment() { NavigationBar { NavigationBarItem( icon = { Icon(Icons.Default.BarChart, contentDescription = null) }, - label = { Text(stringResource(R.string.balances_title)) }, - selected = tab == Tab.BALANCES, + label = { Text(stringResource(R.string.assets_title)) }, + selected = tab == Tab.ASSETS, onClick = { - tab = Tab.BALANCES - if (selectedScope != null) { - model.transactionManager.selectScope(null) - } + tab = Tab.ASSETS + if (viewMode !is ViewMode.Assets) + model.showAssets() } ) @@ -165,32 +166,31 @@ class MainFragment: Fragment() { ) ) { innerPadding -> LaunchedEffect(Unit) { - if (selectedScope == null) { - model.transactionManager.selectScope( - model.settingsManager.getSelectedScope(context).first() - ) - } + val viewMode = model.settingsManager.getViewMode(context).first() + model.setViewMode(viewMode) } - LaunchedEffect(tab, selectedScope) { - setTitle(tab, selectedScope) + LaunchedEffect(tab, viewMode) { + setTitle(tab, viewMode) } - BackHandler(selectedScope != null) { - model.transactionManager.selectScope(null) + BackHandler(viewMode !is ViewMode.Assets) { + model.showAssets() } when (tab) { - Tab.BALANCES -> BalancesComposable( + Tab.ASSETS -> MainComposable( innerPadding = innerPadding, state = balanceState, txResult = txResult, - txStateFilter = txStateFilter, - selectedScope = selectedScope, - selectedCurrencySpec = selectedSpec, + viewMode = viewMode, onGetDemoMoneyClicked = { model.withdrawManager.withdrawTestBalance() - Snackbar.make(requireView(), getString(R.string.settings_test_withdrawal), LENGTH_LONG).show() + Snackbar.make( + requireView(), + getString(R.string.settings_test_withdrawal), + LENGTH_LONG + ).show() }, onBalanceClicked = { model.showTransactions(it.scopeInfo) @@ -203,14 +203,19 @@ class MainFragment: Fragment() { }, onTransactionsDelete = { txIds -> model.transactionManager.deleteTransactions(txIds) { error -> - Toast.makeText(context, error.userFacingMsg, Toast.LENGTH_LONG).show() + Toast.makeText(context, error.userFacingMsg, Toast.LENGTH_LONG) + .show() } }, onShowBalancesClicked = { - if (model.transactionManager.selectedScope.value != null) { - model.transactionManager.selectScope(null) - } + model.showAssets() }, + onStatementClicked = { + findNavController().navigate( + R.id.nav_donau_statement, + bundleOf("donationStatementSig" to it), + ) + } ) Tab.SETTINGS -> SettingsView( innerPadding = innerPadding, @@ -265,24 +270,16 @@ class MainFragment: Fragment() { override fun onStart() { super.onStart() - model.balanceManager.loadBalances() - model.balanceManager.state.observe(viewLifecycleOwner) { res -> - if (res is BalanceState.Success) { - if (res.balances.size == 1) { - // pre-select on startup if it's the only one - model.transactionManager.selectScope(res.balances.first().scopeInfo) - } - } - } + model.balanceManager.loadAssets(model.viewMode.value is ViewMode.Assets) } - private fun setTitle(tab: Tab, scope: ScopeInfo?) { + private fun setTitle(tab: Tab, viewMode: ViewMode?) { (requireActivity() as AppCompatActivity).apply { supportActionBar?.title = when (tab) { - Tab.BALANCES -> if (scope != null) { - getString(R.string.transactions_title) - } else { - getString(R.string.balances_title) + Tab.ASSETS -> when(viewMode) { + is ViewMode.Assets -> getString(R.string.assets_title) + is ViewMode.Transactions -> getString(R.string.transactions_title) + null -> getString(R.string.loading) } Tab.SETTINGS -> getString(R.string.menu_settings) diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. + * (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 @@ -56,6 +56,8 @@ import net.taler.wallet.transactions.TransactionManager import net.taler.wallet.transactions.TransactionStateFilter import net.taler.wallet.withdraw.WithdrawManager import androidx.core.net.toUri +import net.taler.wallet.donau.DonauManager +import net.taler.wallet.main.ViewMode const val TAG = "taler-wallet" const val OBSERVABILITY_LIMIT = 100 @@ -121,6 +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) private val mAuthenticated = MutableStateFlow(false) val authenticated: StateFlow<Boolean> = mAuthenticated @@ -134,6 +137,9 @@ class MainViewModel( private val mScanCodeEvent = MutableLiveData<Event<Boolean>>() val scanCodeEvent: LiveData<Event<Boolean>> = mScanCodeEvent + private val mViewMode = MutableStateFlow<ViewMode>(ViewMode.Assets) + val viewMode: StateFlow<ViewMode> = mViewMode + @set:Synchronized private var scanQrContext = ScanQrContext.Unknown @@ -160,7 +166,7 @@ class MainViewModel( // Only update balances when we're told they changed if (payload.type == "balance-change") viewModelScope.launch(Dispatchers.Main) { - balanceManager.loadBalances() + balanceManager.loadAssets() } if (payload.type in observabilityNotifications && payload.event != null) { @@ -179,8 +185,9 @@ class MainViewModel( // update currently selected transaction list if (payload.type == "transaction-state-transition") { transactionManager.getTransactionById(id)?.let { tx -> - if (transactionManager.selectedScope.value in tx.scopes) { - transactionManager.loadTransactions() + val v = viewMode.value + if (v is ViewMode.Transactions && v.selectedScope in tx.scopes) { + transactionManager.loadTransactions(v.selectedScope) } } } @@ -198,13 +205,37 @@ 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 selectScope(scopeInfo: ScopeInfo?) { + if (scopeInfo != null) { + setViewMode(ViewMode.Transactions(scopeInfo)) + } else { + setViewMode(ViewMode.Assets) + } + } + + fun showAssets() { + if (viewMode.value != ViewMode.Assets) { + selectScope(null) + } + } + /** * Navigates to the given scope info's transaction list, when [MainFragment] is shown. */ @UiThread fun showTransactions(scopeInfo: ScopeInfo, stateFilter: TransactionStateFilter? = null) { - Log.d(TAG, "selectedScope should change to $scopeInfo") - transactionManager.selectScope(scopeInfo, stateFilter) + mViewMode.value = ViewMode.Transactions(scopeInfo, stateFilter = stateFilter) } @UiThread diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -23,17 +23,17 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.distinctUntilChanged import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import net.taler.common.Amount 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.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeManager -import org.json.JSONObject @Serializable data class BalanceResponse( @@ -45,12 +45,14 @@ data class GetCurrencySpecificationResponse( val currencySpecification: CurrencySpecification, ) +// TODO: rename to AssetsState sealed class BalanceState { data object None: BalanceState() data object Loading: BalanceState() data class Success( val balances: List<BalanceItem>, + val statements: List<DonauStatement>, ): BalanceState() data class Error( @@ -58,6 +60,7 @@ sealed class BalanceState { ): BalanceState() } +// TODO: rename to AssetsManager class BalanceManager( private val api: WalletBackendApi, private val scope: CoroutineScope, @@ -69,54 +72,58 @@ class BalanceManager( private val mState = MutableLiveData<BalanceState>(BalanceState.None) val state: LiveData<BalanceState> = mState.distinctUntilChanged() - @UiThread - fun loadBalances() { - if (mState.value == BalanceState.None) { - mState.value = BalanceState.Loading + 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, + )) + } } + } - scope.launch { - val response = api.request("getBalances", BalanceResponse.serializer()) - response.onError { + 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)) - } - response.onSuccess { - mBalances.postValue(it.balances) - scope.launch { - // Fetch missing currency specs for all balances - it.balances.forEach { balance -> - exchangeManager.getCurrencySpecification(balance.scopeInfo) - } - - mState.postValue( - BalanceState.Success(it.balances.map { balance -> - val spec = exchangeManager.getCurrencySpecification(balance.scopeInfo) - balance.copy( - available = balance.available.withSpec(spec), - pendingIncoming = balance.pendingIncoming.withSpec(spec), - pendingOutgoing = balance.pendingOutgoing.withSpec(spec), - ) - }), + }.onSuccess { + it.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), ) + }.let { balances -> + res = balances + mBalances.postValue(balances) } } - } + return res } - private suspend fun getCurrencySpecification(scopeInfo: ScopeInfo): CurrencySpecification? { - var spec: CurrencySpecification? = null - api.request("getCurrencySpecification", GetCurrencySpecificationResponse.serializer()) { - val json = Json.encodeToString(scopeInfo) - Log.d(TAG, "BalanceManager: $json") - put("scope", JSONObject(json)) - }.onSuccess { - spec = it.currencySpecification - }.onError { - Log.e(TAG, "Error getting currency spec for scope $scopeInfo: $it") - } - - return spec + private suspend fun loadDonauStatements(): List<DonauStatement>? { + var res: 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 = it.statements + } + return res } @UiThread diff --git a/wallet/src/main/java/net/taler/wallet/balances/Balances.kt b/wallet/src/main/java/net/taler/wallet/balances/Balances.kt @@ -69,15 +69,14 @@ sealed class ScopeInfo { else -> null } - return PrefsScopeInfo.newBuilder().apply { - setType(type) - setCurrency(currency) - if (url != null) { - setUrl(url) - } else { - clearUrl() - } - }.build() + return PrefsScopeInfo + .newBuilder() + .setType(type) + .setCurrency(currency) + .apply { + if (url != null) + setUrl(url) + }.build() } companion object { 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,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2024 Taler Systems S.A. + * (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 @@ -18,13 +18,16 @@ 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.Arrangement +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 @@ -41,7 +44,6 @@ import androidx.compose.material3.OutlinedCard import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource @@ -50,7 +52,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount -import net.taler.common.CurrencySpecification import net.taler.wallet.R import net.taler.wallet.balances.ScopeInfo.Auditor import net.taler.wallet.balances.ScopeInfo.Exchange @@ -59,64 +60,51 @@ 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.transactions.Transaction -import net.taler.wallet.transactions.TransactionStateFilter -import net.taler.wallet.transactions.TransactionsComposable -import net.taler.wallet.transactions.TransactionsResult +import net.taler.wallet.donau.DonauStatement import net.taler.wallet.withdraw.WithdrawalError +// TODO: rename to AssetsComposable @Composable fun BalancesComposable( innerPadding: PaddingValues, state: BalanceState, - txResult: TransactionsResult, - txStateFilter: TransactionStateFilter?, - selectedScope: ScopeInfo?, - selectedCurrencySpec: CurrencySpecification?, onGetDemoMoneyClicked: () -> Unit, onBalanceClicked: (balance: BalanceItem) -> Unit, onPendingClicked: (balance: BalanceItem) -> Unit, - onTransactionClicked: (tx: Transaction) -> Unit, - onTransactionsDelete: (txIds: List<String>) -> Unit, - onShowBalancesClicked: () -> Unit, + onStatementClicked: (sig: String) -> Unit, ) { when (state) { is BalanceState.None -> {} is BalanceState.Loading -> LoadingScreen() is BalanceState.Error -> WithdrawalError(state.error) is BalanceState.Success -> if (state.balances.isNotEmpty()) { - if (selectedScope == null) { - LazyColumn( - Modifier - .consumeWindowInsets(innerPadding) - .fillMaxSize(), - contentPadding = innerPadding, - ) { - items(state.balances, key = { it.scopeInfo.hashCode() }) { balance -> - BalanceRow(balance, - onClick = { onBalanceClicked(balance) }, - onPendingClick = { onPendingClicked(balance) }, - ) - } + LazyColumn( + Modifier + .consumeWindowInsets(innerPadding) + .fillMaxSize(), + contentPadding = innerPadding, + ) { + if (state.balances.isNotEmpty()) stickyHeader { + SectionHeader { Text(stringResource(R.string.assets_section_balances)) } + } + + items(state.balances, key = { it.scopeInfo.hashCode() }) { balance -> + BalanceRow( + balance, + onClick = { onBalanceClicked(balance) }, + onPendingClick = { onPendingClicked(balance) }, + ) } - } else { - val balance = remember(state.balances, selectedScope) { - state.balances.find { it.scopeInfo == selectedScope } + + if (state.statements.isNotEmpty()) stickyHeader { + SectionHeader { Text(stringResource(R.string.assets_section_statements)) } } - balance?.let { - TransactionsComposable( - innerPadding = innerPadding, - balance = it, - currencySpec = selectedCurrencySpec, - txResult = txResult, - txStateFilter = txStateFilter, - onTransactionClick = onTransactionClicked, - onTransactionsDelete = onTransactionsDelete, - onShowBalancesClicked = onShowBalancesClicked, + items(state.statements, key = { it.year }) { statement -> + StatementRow( + statement, + onClick = { onStatementClicked(statement.donationStatementSig) }, ) - } ?: run { - onShowBalancesClicked() } } } else { @@ -129,6 +117,22 @@ fun BalancesComposable( } @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, @@ -179,6 +183,35 @@ fun BalanceRow( } @Composable +fun StatementRow( + statement: DonauStatement, + onClick: () -> Unit, +) { + OutlinedCard(Modifier.cardPaddings()) { + Column { + ListItem( + modifier = Modifier + .animateContentSize() + .clickable { onClick() } + .padding(vertical = 6.dp), + headlineContent = { + Text( + "${statement.year}", + style = MaterialTheme.typography.displaySmall, + ) + }, + overlineContent = { + val host = statement.host + if (host != null) ProvideTextStyle(MaterialTheme.typography.bodySmall) { + Text(stringResource(R.string.balance_scope_exchange, host)) + } + } + ) + } + } +} + +@Composable fun PendingComposable( balance: BalanceItem, onClick: () -> Unit, @@ -277,17 +310,11 @@ fun BalancesComposablePreview() { TalerSurface { BalancesComposable( innerPadding = PaddingValues(0.dp), - state = BalanceState.Success(balances), - txResult = TransactionsResult.Success(listOf()), - txStateFilter = null, - selectedScope = null, - selectedCurrencySpec = null, + state = BalanceState.Success(balances, listOf()), onGetDemoMoneyClicked = {}, onBalanceClicked = {}, - onTransactionClicked = {}, - onTransactionsDelete = {}, - onShowBalancesClicked = {}, onPendingClicked = {}, + onStatementClicked = {}, ) } } @@ -296,19 +323,13 @@ fun BalancesComposablePreview() { @Composable fun BalancesComposableEmptyPreview() { TalerSurface { - BalancesComposable( + BalancesComposable ( innerPadding = PaddingValues(0.dp), - state = BalanceState.Success(listOf()), - txResult = TransactionsResult.Success(listOf()), - txStateFilter = null, - selectedScope = null, - selectedCurrencySpec = null, + state = BalanceState.Success(listOf(), listOf()), onGetDemoMoneyClicked = {}, onBalanceClicked = {}, - onTransactionClicked = {}, - onTransactionsDelete = {}, - onShowBalancesClicked = {}, onPendingClicked = {}, + onStatementClicked = {}, ) } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/compose/ErrorComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/ErrorComposable.kt @@ -0,0 +1,157 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorInfo + +@OptIn(ExperimentalSerializationApi::class) +@Composable +fun ErrorComposable( + error: TalerErrorInfo, + devMode: Boolean, + onClose: (() -> Unit)? = null, +) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + horizontalAlignment = CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.ErrorOutline, + modifier = Modifier + .size(120.dp) + .padding(bottom = 8.dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = stringResource(R.string.error), + style = MaterialTheme.typography.displaySmall, + ) + + val jsonError = remember(error) { + val json = Json { + prettyPrint = true + prettyPrintIndent = " " + } + json.encodeToString(error) + } + + Text( + modifier = Modifier.padding(bottom = 16.dp), + style = MaterialTheme.typography.bodyLarge, + text = if (!devMode) { + error.userFacingMsg + } else jsonError, + fontFamily = if (devMode) { + FontFamily.Monospace + } else { + FontFamily.Default + }, + ) + + Row( + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + CopyToClipboardButton( + label = "Error", + content = jsonError, + ) + + ShareButton( + content = jsonError, + ) + } + + if (onClose != null) Button( + modifier = Modifier.padding(bottom = 16.dp), + onClick = onClose, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(R.string.close)) + } + + BottomInsetsSpacer() + } +} + +@Preview +@Composable +fun ErrorComposablePreview(devMode: Boolean = false) { + TalerSurface { + ErrorComposable( + error = TalerErrorInfo.makeCustomError( + message = "Some random error", + ), + devMode = devMode, + onClose = {}, + ) + } +} + +@Preview +@Composable +fun ErrorComposableDevPreview() { + ErrorComposablePreview( + devMode = true, + ) +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauComposables.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauComposables.kt @@ -0,0 +1,191 @@ +/* + * 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.donau + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.itemsIndexed +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.wallet.R +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.WarningLabel +import net.taler.wallet.payment.DonauStatus + +@Composable +fun DonauToggle( + donauStatus: DonauStatus, + useDonau: Boolean = false, + onToggleDonau: (Boolean) -> Unit, +) { + if (donauStatus !is DonauStatus.Unavailable) { + Column { + Row( + Modifier + .fillMaxWidth() + .padding(9.dp), + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.donau_toggle_label), + ) + + Switch( + checked = useDonau, + onCheckedChange = onToggleDonau, + ) + } + + if (useDonau) when (donauStatus) { + is DonauStatus.Mismatch -> { + WarningLabel(stringResource( + R.string.donau_warning_mismatch, + cleanExchange(donauStatus.donauInfo.donauBaseUrl))) + } + + is DonauStatus.Unset -> { + WarningLabel(stringResource(R.string.donau_warning_unset)) + } + + // no need to show anything + else -> {} + } + } + } +} + +@Composable +fun DonauSelector( + donauStatus: DonauStatus, + showDialog: Boolean, + onDismiss: () -> Unit, + onSetup: (donauBaseUrl: String) -> Unit, +) { + var selectedDonauIndex by remember { mutableIntStateOf(0) } + + val donauUrls = when(donauStatus) { + is DonauStatus.Mismatch -> donauStatus.donauUrls + is DonauStatus.Unset -> donauStatus.donauUrls + else -> return + } + + if (showDialog) AlertDialog( + title = { Text(stringResource(R.string.donau_selector_title)) }, + text = { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(donauUrls) { i, url -> + DonauSelectorItem( + donauBaseUrl = url, + selected = i == selectedDonauIndex, + onSelected = { selectedDonauIndex = i }, + ) + } + } + }, + confirmButton = { + Button(onClick = { + onSetup(donauUrls[selectedDonauIndex]) + onDismiss() + }) { + Text(stringResource(R.string.donau_selector_confirm)) + } + }, + dismissButton = { + TextButton (onClick = { onDismiss() }) { + Text(stringResource(R.string.cancel)) + } + }, + onDismissRequest = { + onDismiss() + }, + ) +} + +@Composable +fun DonauSelectorItem( + donauBaseUrl: String, + selected: Boolean, + onSelected: () -> Unit, +) { + Row(Modifier + .padding(bottom = 5.dp) + .fillMaxWidth() + .clickable { onSelected() }, + verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selected, + onClick = onSelected, + ) + + Text( + modifier = Modifier.weight(1f), + text = cleanExchange(donauBaseUrl) + ) + } +} + +@Preview +@Composable +fun DonauSelectorPreview() { + TalerSurface { + val donauInfo = DonauInfo(donauBaseUrl = "https://donau.test.taler.net/", taxPayerId = "1234") + val donauUrls = listOf( + "https://donau.head.taler.net/", + "https://donau.test.taler.net/", + "https://donau.demo.taler.net/", + ) + + var donauStatus: DonauStatus by remember { mutableStateOf(DonauStatus.Unset(donauUrls)) } + + Column(Modifier.fillMaxSize()) { + Button(onClick = { donauStatus = DonauStatus.Unset(donauUrls) }) { + Text("DonauStatus.Unset") + } + + Button(onClick = { donauStatus = DonauStatus.Mismatch(donauInfo, donauUrls) }) { + Text("DonauStatus.Mismatch") + } + } + + DonauSelector(donauStatus, + showDialog = donauStatus !is DonauStatus.Unavailable, + onDismiss = { donauStatus = DonauStatus.Unavailable }, + onSetup = { donauStatus = DonauStatus.Unavailable } + ) + } +} +\ 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 @@ -0,0 +1,62 @@ +/* + * 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.donau + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import net.taler.wallet.TAG +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.backend.WalletBackendApi + +class DonauManager( + private val api: WalletBackendApi, + private val scope: CoroutineScope, +) { + private val mDonauStatus = MutableStateFlow<GetDonauStatus>(GetDonauStatus.None) + val donauStatus = mDonauStatus.asStateFlow() + + fun setDonau( + info: DonauInfo, + onSuccess: () -> Unit, + onError: (error: TalerErrorInfo) -> Unit, + ) = scope.launch { + api.request<Unit>("setDonau") { + put("donauBaseUrl", info.donauBaseUrl) + put("taxPayerId", info.taxPayerId) + }.onError { error -> + Log.e(TAG, "Error setDonau $error") + onError(error) + }.onSuccess { + getDonau() + onSuccess() + } + } + + fun getDonau() = scope.launch { + mDonauStatus.value = GetDonauStatus.Loading + api.request("getDonau", GetDonauResponse.serializer()) + .onError { error -> + Log.e(TAG, "Error getDonau $error") + mDonauStatus.value = GetDonauStatus.Error(error) + }.onSuccess { res -> + mDonauStatus.value = GetDonauStatus.Success(res.currentDonauInfo) + } + } +} +\ 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 @@ -0,0 +1,68 @@ +/* + * 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.donau + +import androidx.core.net.toUri +import kotlinx.serialization.Serializable +import net.taler.common.Amount +import net.taler.wallet.backend.TalerErrorInfo + +@Serializable +data class DonauInfo( + val donauBaseUrl: String, + val taxPayerId: String, +) + +@Serializable +data class GetDonauResponse( + val currentDonauInfo: DonauInfo? = null, +) + +@Serializable +sealed class GetDonauStatus { + data object None: GetDonauStatus() + data object Loading: GetDonauStatus() + data class Success(val donauInfo: DonauInfo?): GetDonauStatus() + data class Error(val error: TalerErrorInfo): GetDonauStatus() +} + +@Serializable +data class DonauStatement( + val total: Amount, + val year: Int, + val legalDomain: String, + val uri: String, + val donationStatementSig: String, + val donauPub: String, +) { + val host: String? by lazy { + uri.toUri().host + } +} + +@Serializable +data class GetDonauStatementsResponse( + val statements: List<DonauStatement>, +) + +@Serializable +sealed class GetDonauStatementsStatus { + data object None: GetDonauStatementsStatus() + data object Loading: GetDonauStatementsStatus() + data class Success(val statements: List<DonauStatement>) : GetDonauStatementsStatus() + data class Error(val error: TalerErrorInfo): GetDonauStatementsStatus() +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauStatementComposable.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauStatementComposable.kt @@ -0,0 +1,88 @@ +/* + * 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.donau + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.R +import net.taler.wallet.compose.QrCodeUriComposable +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfoComposable + +@Composable +fun DonauStatementComposable( + statement: DonauStatement, +) { + // TODO: create common instruction + QR composable + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + style = MaterialTheme.typography.titleLarge, + text = stringResource(R.string.donau_statement_instruction), + textAlign = TextAlign.Center, + ) + + QrCodeUriComposable( + talerUri = statement.uri, + clipBoardLabel = "Donau", + buttonText = stringResource(id = R.string.copy), + shareAsQrCode = true, + ) + + val host = statement.host + if (host != null) TransactionInfoComposable( + label = stringResource(R.string.donau_statement_tax_authority), + info = host, + ) + + TransactionInfoComposable( + label = stringResource(R.string.donau_statement_legal_domain), + info = statement.legalDomain, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.amount_total), + amount = statement.total, + amountType = AmountType.Neutral, + ) + + TransactionInfoComposable( + label = stringResource(R.string.donau_statement_year), + info = statement.year.toString(), + ) + + BottomInsetsSpacer() + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/DonauStatementFragment.kt b/wallet/src/main/java/net/taler/wallet/donau/DonauStatementFragment.kt @@ -0,0 +1,89 @@ +/* + * 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.donau + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +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.LoadingScreen +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.showError + +class DonauStatementFragment: Fragment() { + private val model: MainViewModel by activityViewModels() + + private lateinit var donationStatementSig: String + private val mStatement = MutableStateFlow<DonauStatement?>(null) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = ComposeView(requireContext()).apply { + donationStatementSig = arguments?.getString("donationStatementSig") + ?: error("no donationStatementSig provided") + + setContent { + val statement by mStatement.collectAsStateLifecycleAware() + statement?.let { + DonauStatementComposable(it) + } ?: run { + LoadingScreen() + } + } + } + + override fun onStart() { + super.onStart() + model.balanceManager.loadAssets(true) + val supportActionBar = (requireActivity() as? AppCompatActivity)?.supportActionBar + 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.find { + it.donationStatementSig == donationStatementSig + }?.let { + mStatement.value = it + supportActionBar?.title = + getString(R.string.donau_statement_title_year, it.year) + } + } + + else -> {} + } + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/donau/SetDonauFragment.kt b/wallet/src/main/java/net/taler/wallet/donau/SetDonauFragment.kt @@ -0,0 +1,200 @@ +/* + * 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.donau + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily.Companion.Monospace +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import net.taler.common.showError +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.donau.GetDonauStatus.Error +import net.taler.wallet.donau.GetDonauStatus.Loading +import net.taler.wallet.donau.GetDonauStatus.None +import net.taler.wallet.donau.GetDonauStatus.Success +import net.taler.wallet.showError + +class SetDonauFragment: Fragment() { + val model: MainViewModel by activityViewModels() + val donauManager by lazy { model.donauManager } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + val saveShouldExit = arguments?.getBoolean("saveShouldExit") == true + val donauBaseUrl = arguments?.getString("donauBaseUrl") + + setContent { + TalerSurface { + val donauStatus by donauManager.donauStatus.collectAsState() + val devMode by model.devMode.observeAsState() + when(val status = donauStatus) { + Loading, None -> LoadingScreen() + is Success -> SetDonauComposable( + initialUrl = donauBaseUrl, + donauInfo = status.donauInfo, + onSetDonauInfo = { info -> + donauManager.setDonau(info, + { + Toast.makeText( + requireContext(), + getString(R.string.donau_ready), + Toast.LENGTH_LONG).show() + if (saveShouldExit) { + findNavController().popBackStack() + } + }, + { error -> + if(model.devMode.value == true) { + showError(error) + } else { + showError(error.userFacingMsg) + } + } + ) + }, + ) + is Error -> ErrorComposable( + error = status.error, + devMode = devMode == true, + ) + } + + LaunchedEffect(Unit) { + donauManager.getDonau() + } + } + } + } +} + +@Composable +fun SetDonauComposable( + initialUrl: String? = null, + donauInfo: DonauInfo?, + onSetDonauInfo: (info: DonauInfo) -> Unit, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var donauBaseUrl by remember { mutableStateOf(initialUrl ?: donauInfo?.donauBaseUrl ?: "") } + var taxPayerId by remember { mutableStateOf(donauInfo?.taxPayerId ?: "") } + + Column(Modifier.fillMaxSize()) { + OutlinedTextField( + modifier = Modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ).fillMaxWidth(), + value = donauBaseUrl, + onValueChange = { + donauBaseUrl = it + }, + singleLine = true, + isError = donauBaseUrl.isBlank(), + label = { Text(stringResource(R.string.donau_url)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), + ) + + OutlinedTextField( + modifier = Modifier.padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ).fillMaxWidth(), + value = taxPayerId, + onValueChange = { + taxPayerId = it + }, + singleLine = true, + textStyle = TextStyle(fontFamily = Monospace), + isError = taxPayerId.isBlank(), + label = { Text(stringResource(R.string.donau_id)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Exit) }), + ) + + Button( + modifier = Modifier + .padding(horizontal = 16.dp) + .align(Alignment.End), + onClick = { + focusManager.clearFocus() + keyboardController?.hide() + onSetDonauInfo(DonauInfo( + donauBaseUrl = donauBaseUrl, + taxPayerId = taxPayerId, + )) + }, + enabled = donauBaseUrl.isNotBlank() && + taxPayerId.isNotBlank() + ) { + Text(stringResource(R.string.save)) + } + + BottomInsetsSpacer() + } +} + +@Preview +@Composable +fun SetDonauComposablePreview() { + TalerSurface { + SetDonauComposable("https://donau.test.taler.net/", null) {} + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -175,7 +175,7 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { } override fun onPeerReceive(item: ExchangeItem) { - transactionManager.selectScope(item.scopeInfo) + model.selectScope(item.scopeInfo) findNavController().navigate(R.id.nav_peer_pull) } diff --git a/wallet/src/main/java/net/taler/wallet/main/MainComposable.kt b/wallet/src/main/java/net/taler/wallet/main/MainComposable.kt @@ -0,0 +1,71 @@ +/* + * 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.main + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import net.taler.wallet.balances.BalanceItem +import net.taler.wallet.balances.BalanceState +import net.taler.wallet.balances.BalancesComposable +import net.taler.wallet.transactions.Transaction +import net.taler.wallet.transactions.TransactionsComposable +import net.taler.wallet.transactions.TransactionsResult + +@Composable +fun MainComposable( + innerPadding: PaddingValues, + state: BalanceState, + txResult: TransactionsResult, + viewMode: ViewMode, + onGetDemoMoneyClicked: () -> Unit, + onBalanceClicked: (balance: BalanceItem) -> Unit, + onPendingClicked: (balance: BalanceItem) -> Unit, + onStatementClicked: (sig: String) -> Unit, + onTransactionClicked: (tx: Transaction) -> Unit, + onTransactionsDelete: (txIds: List<String>) -> Unit, + onShowBalancesClicked: () -> Unit, +) { + when(viewMode) { + is ViewMode.Assets -> BalancesComposable( + innerPadding = innerPadding, + state = state, + onGetDemoMoneyClicked = onGetDemoMoneyClicked, + onBalanceClicked = onBalanceClicked, + onPendingClicked = onPendingClicked, + onStatementClicked = onStatementClicked, + ) + + is ViewMode.Transactions -> { + val balance = remember(state, viewMode) { + (state as? BalanceState.Success)?.balances?.find { + it.scopeInfo == viewMode.selectedScope + } + } + + if (balance != null) TransactionsComposable( + innerPadding = innerPadding, + viewMode = viewMode, + balance = balance, + txResult = txResult, + onTransactionClick = onTransactionClicked, + onTransactionsDelete = onTransactionsDelete, + onShowBalancesClicked = onShowBalancesClicked, + ) + } + } +} +\ 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 @@ -0,0 +1,65 @@ +/* + * 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.main + +import net.taler.common.CurrencySpecification +import net.taler.wallet.PrefsViewMode +import net.taler.wallet.ViewModeType +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() + } + + 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), + ) + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -34,6 +34,8 @@ import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.donau.DonauInfo +import net.taler.wallet.donau.GetDonauResponse import net.taler.wallet.exchanges.ExchangeManager import net.taler.wallet.payment.PayStatus.AlreadyPaid import net.taler.wallet.payment.PayStatus.InsufficientBalance @@ -212,11 +214,13 @@ class PaymentManager( transactionId: String, choiceIndex: Int? = null, automaticExecution: Boolean = false, + useDonau: Boolean = false, ) = scope.launch { mPayStatus.postValue(PayStatus.Loading) api.request("confirmPay", ConfirmPayResult.serializer()) { choiceIndex?.let { put("choiceIndex", it) } put("transactionId", transactionId) + put("useDonau", useDonau) }.onError { handleError("confirmPay", it) }.onSuccess { response -> @@ -233,6 +237,33 @@ class PaymentManager( } } + suspend fun checkDonauForChoice( + choiceDetails: PayChoiceDetails + ): DonauStatus { + val taxReceipt = (choiceDetails.outputs) + .find { it is ContractOutput.TaxReceipt } + as ContractOutput.TaxReceipt? + + return if (taxReceipt != null) { + var donauInfo: DonauInfo? = null + api.request("getDonau", GetDonauResponse.serializer()) + .onSuccess { donauInfo = it.currentDonauInfo } + + if (donauInfo == null) { + DonauStatus.Unset(taxReceipt.donauUrls.distinct()) + } else if (taxReceipt.donauUrls.contains(donauInfo!!.donauBaseUrl)) { + DonauStatus.Available + } else { + DonauStatus.Mismatch( + donauInfo = donauInfo!!, + donauUrls = taxReceipt.donauUrls.distinct(), + ) + } + } else { + DonauStatus.Unavailable + } + } + fun checkPayForTemplate(url: String) = scope.launch { mPayStatus.value = PayStatus.Loading api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) { diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentResponses.kt @@ -25,6 +25,7 @@ import net.taler.common.Amount import net.taler.common.ContractTerms import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.donau.DonauInfo import net.taler.wallet.payment.InsufficientBalanceHint.AgeRestricted import net.taler.wallet.payment.InsufficientBalanceHint.ExchangeMissingGlobalFees import net.taler.wallet.payment.InsufficientBalanceHint.FeesNotCovered @@ -328,6 +329,16 @@ enum class TokenAvailabilityHint { MerchantUntrusted, } +sealed class DonauStatus { + data object Unavailable: DonauStatus() + data object Available: DonauStatus() + data class Unset(val donauUrls: List<String>): DonauStatus() + data class Mismatch( + val donauInfo: DonauInfo, + val donauUrls: List<String> + ): DonauStatus() +} + @Serializable sealed class ConfirmPayResult { @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt @@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll @@ -58,6 +60,7 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -93,6 +96,9 @@ import net.taler.wallet.compose.BottomButtonBox import net.taler.wallet.compose.ExpandableSection import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.cardPaddings +import net.taler.wallet.donau.DonauInfo +import net.taler.wallet.donau.DonauSelector +import net.taler.wallet.donau.DonauToggle import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.InsufficientBalance import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible import net.taler.wallet.payment.TokenAvailabilityHint.MerchantUnexpected @@ -106,9 +112,11 @@ import net.taler.wallet.systemBarsPaddingBottom @Composable fun PromptPaymentComposable( status: PayStatus.Choices, - onConfirm: (choiceIndex: Int?) -> Unit, + onConfirm: (choiceIndex: Int?, useDonau: Boolean) -> Unit, onCancel: () -> Unit, onClickImage: (Bitmap) -> Unit, + onSetupDonau: (donauBaseUrl: String) -> Unit, + checkDonauStatus: suspend (choiceIndex: Int) -> DonauStatus, ) { val contractTerms = status.contractTerms var showCancelDialog by rememberSaveable { mutableStateOf(false) } @@ -149,20 +157,27 @@ fun PromptPaymentComposable( if (contractTerms is ContractTerms.V1) { var choicesExpanded by rememberSaveable { mutableStateOf(true) } var selectedIndex by rememberSaveable { mutableIntStateOf(status.defaultChoiceIndex ?: 0) } + var donauStatus: DonauStatus by remember { mutableStateOf(DonauStatus.Unavailable) } ExpandableSection( expanded = choicesExpanded, setExpanded = { choicesExpanded = it }, header = { Text(stringResource(R.string.payment_section_choices)) }, ) { ChoicesSection( - status, - contractTerms.tokenFamilies, - selectedIndex, - contractTerms.merchantBaseUrl, + status = status, + tokenFamilies = contractTerms.tokenFamilies, + selectedIndex = selectedIndex, + merchantBaseUrl =contractTerms.merchantBaseUrl, onSelect = { index -> selectedIndex = index }, - onConfirm = { index -> onConfirm(index) }, + onConfirm = onConfirm, + donauStatus = donauStatus, + onSetupDonau = onSetupDonau, ) } + + LaunchedEffect(selectedIndex) { + donauStatus = checkDonauStatus(selectedIndex) + } } } @@ -211,7 +226,7 @@ fun PromptPaymentComposable( Button( modifier = Modifier.systemBarsPaddingBottom(), enabled = choice.details is PaymentPossible, - onClick = { onConfirm(null) }, + onClick = { onConfirm(null, false) }, ) { if (choice.details is PaymentPossible) { Text(stringResource( @@ -239,7 +254,9 @@ fun MerchantSection( val merchant = contractTerms.merchant Column( - modifier = Modifier.padding(16.dp).fillMaxWidth(), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), horizontalAlignment = CenterHorizontally, ) { // MERCHANT LOGO @@ -432,7 +449,9 @@ fun ChoicesSection( selectedIndex: Int, merchantBaseUrl: String, onSelect: (choiceIndex: Int) -> Unit, - onConfirm: (choiceIndex: Int) -> Unit, + onConfirm: (choiceIndex: Int, useDonau: Boolean) -> Unit, + donauStatus: DonauStatus, + onSetupDonau: (donauBaseUrl: String) -> Unit, ) { // TODO: CURRENCIES @@ -440,12 +459,14 @@ fun ChoicesSection( // TODO: LazyColumn would be better, but can't be nested status.choices.forEach { choice -> PaymentChoice( - choice, - tokenFamilies, - merchantBaseUrl, - selectedIndex == choice.choiceIndex, + choice = choice, + tokenFamilies = tokenFamilies, + merchantBaseUrl = merchantBaseUrl, + selected = selectedIndex == choice.choiceIndex, onSelect = { onSelect(choice.choiceIndex) }, - onConfirm = { onConfirm(choice.choiceIndex) }, + donauStatus = donauStatus, + onSetupDonau = onSetupDonau, + onConfirm = { useDonau -> onConfirm(choice.choiceIndex, useDonau) }, ) } } @@ -457,13 +478,25 @@ fun PaymentChoice( merchantBaseUrl: String, selected: Boolean, onSelect: () -> Unit, - onConfirm: () -> Unit, + onConfirm: (useDonau: Boolean) -> Unit, + donauStatus: DonauStatus, + onSetupDonau: (donauBaseUrl: String) -> Unit, ) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val coroutineScope = rememberCoroutineScope() + OutlinedCard( - modifier = Modifier + modifier = Modifier .cardPaddings() .fillMaxWidth() - .animateContentSize() + .bringIntoViewRequester(bringIntoViewRequester) + .animateContentSize { _, _ -> + if (selected) { + coroutineScope.launch { + bringIntoViewRequester.bringIntoView() + } + } + } .clickable { onSelect() }, border = if (selected) { BorderStroke(2.5.dp, MaterialTheme.colorScheme.primary) @@ -528,18 +561,48 @@ fun PaymentChoice( } } + // DONAU TOGGLE/SELECTOR + var useDonau by rememberSaveable { mutableStateOf(false) } + var showSelector by remember { mutableStateOf(false) } + if (choice.details is PaymentPossible) { + if (selected) DonauToggle( + donauStatus = donauStatus, + useDonau = useDonau, + onToggleDonau = { useDonau = it }, + ) + + DonauSelector( + donauStatus = donauStatus, + showDialog = showSelector, + onSetup = { onSetupDonau(it) }, + onDismiss = { showSelector = false }, + ) + } + + val shouldSetDonau = choice.details is PaymentPossible + && useDonau + && donauStatus !is DonauStatus.Available + // CONFIRM BUTTON if (selected) Button( modifier = Modifier .padding(top = 9.dp) .fillMaxWidth(), - onClick = onConfirm, + onClick = { + if (shouldSetDonau) { + showSelector = true + } else { + onConfirm(useDonau) + } + }, enabled = choice.details is PaymentPossible, ) { val tokenDetails = choice.details.tokenDetails Text( if (choice.details is PaymentPossible) { - if (choice.details.amountEffective.isZero()) { + if (shouldSetDonau) { + stringResource(R.string.donau_select_button) + } else if (choice.details.amountEffective.isZero()) { stringResource(R.string.payment_button_confirm_tokens) } else { stringResource( @@ -621,6 +684,10 @@ fun PaymentOutput( merchantBaseUrl = merchantBaseUrl, ) } + + // TODO: ContractOutput.TaxReceipt + + else -> {} } } @@ -635,8 +702,8 @@ fun TokenCard( ) { Card ( modifier = Modifier - .padding(vertical = 5.dp, - ).fillMaxWidth(), + .padding(vertical = 5.dp) + .fillMaxWidth(), ) { Row( modifier = Modifier.padding(vertical = 8.dp), @@ -809,7 +876,7 @@ private val contractTermsV1 = ContractTerms.V1( choices = listOf( ContractChoice( amount = Amount.fromJSONString("KUDOS:10"), - description = "Movie pass discount", + description = "Tax-deductible movie pass discount", maxFee = Amount.fromJSONString("KUDOS:0"), inputs = listOf( ContractInput.Token(tokenFamilySlug = "half-tax", count = 2), @@ -817,6 +884,10 @@ private val contractTermsV1 = ContractTerms.V1( ), outputs = listOf( ContractOutput.Token(tokenFamilySlug = "movie-pass"), + ContractOutput.TaxReceipt( + amount = Amount.fromJSONString("KUDOS:10"), + donauUrls = listOf("https://donau.test.taler.net/"), + ) ), ), @@ -881,7 +952,7 @@ fun PromptPaymentV0Preview() { ), ) ) - ), {}, {}, {}) + ), { _, _ -> }, {}, {}, {}, { DonauStatus.Unavailable }) } } @@ -897,7 +968,7 @@ fun PromptPaymentV1Preview() { PayChoiceDetails( choiceIndex = 0, amountRaw = contractTermsV1.choices[0].amount, - description = "Movie pass discount", + description = "Tax-deductible movie pass discount", inputs = contractTermsV1.choices[0].inputs, outputs = contractTermsV1.choices[0].outputs, details = PaymentPossible( @@ -950,6 +1021,14 @@ fun PromptPaymentV1Preview() { ), ), ) - ), {}, {}, {}) + ), { _, _ -> }, {}, {}, {}, { + DonauStatus.Mismatch( + donauInfo = DonauInfo("https://donau.test.taler.net/", "123"), + donauUrls = listOf( + "https://donau.demo.taler.net/", + "https://donau.head.taler.net/", + ), + ) + }) } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -25,6 +25,7 @@ import android.view.ViewGroup import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope @@ -40,8 +41,6 @@ import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.showError -// TODO: - class PromptPaymentFragment: Fragment(), ProductImageClickListener { private val model: MainViewModel by activityViewModels() private val paymentManager by lazy { model.paymentManager } @@ -62,8 +61,12 @@ class PromptPaymentFragment: Fragment(), ProductImageClickListener { is PayStatus.Checked -> {} // does not apply, only used for templates is PayStatus.Choices -> { PromptPaymentComposable(status, - onConfirm = { index -> - paymentManager.confirmPay(status.transactionId, index) + onConfirm = { index, useDonau -> + paymentManager.confirmPay( + transactionId = status.transactionId, + choiceIndex = index, + useDonau = useDonau, + ) }, onCancel = { transactionManager.abortTransaction( @@ -88,7 +91,19 @@ class PromptPaymentFragment: Fragment(), ProductImageClickListener { }, onClickImage = { bitmap -> onImageClick(bitmap) - } + }, + checkDonauStatus = { index -> + paymentManager.checkDonauForChoice(status.choices[index]) + }, + onSetupDonau = { donauBaseUrl -> + findNavController().navigate( + R.id.nav_settings_donau, + bundleOf( + "donauBaseUrl" to donauBaseUrl, + "saveShouldExit" to true, + ), + ) + }, ) } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -33,6 +33,7 @@ import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.main.ViewMode import net.taler.wallet.compose.AmountScope import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware @@ -54,17 +55,17 @@ class OutgoingPullFragment : Fragment() { setContent { TalerSurface { val state by peerManager.pullState.collectAsStateLifecycleAware() - val selectedScope by transactionManager.selectedScope.collectAsStateLifecycleAware() + val viewMode by model.viewMode.collectAsStateLifecycleAware() OutgoingPullComposable( state = state, onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, onTosAccept = this@OutgoingPullFragment::onTosAccept, - defaultScope = remember { selectedScope }, + defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, scopes = balanceManager.getScopes(), getCurrencySpec = exchangeManager::getSpecForScopeInfo, checkPeerPullCredit = { amount, loading -> - transactionManager.selectScope(amount.scope) - peerManager.checkPeerPullCredit(amount.amount, + model.selectScope(amount.scope) + peerManager.checkPeerPullCredit(amount.amount, scopeInfo = amount.scope, loading = loading, ) diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -22,6 +22,7 @@ import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -33,6 +34,7 @@ import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.main.ViewMode import net.taler.wallet.compose.AmountScope import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware @@ -65,13 +67,16 @@ class OutgoingPushFragment : Fragment() { setContent { TalerSurface { val state = peerManager.pushState.collectAsStateLifecycleAware().value - val selectedScope by transactionManager.selectedScope.collectAsStateLifecycleAware() + val viewMode by model.viewMode.collectAsStateLifecycleAware() OutgoingPushComposable( state = state, - defaultScope = selectedScope, + defaultScope = remember { (viewMode as? ViewMode.Transactions)?.selectedScope }, scopes = balanceManager.getScopes(), getCurrencySpec = exchangeManager::getSpecForScopeInfo, - getFees = { peerManager.checkPeerPushFees(it.amount, restrictScope = it.scope) }, + getFees = { + model.selectScope(it.scope) + peerManager.checkPeerPushFees(it.amount, restrictScope = it.scope) + }, onSend = this@OutgoingPushFragment::onSend, onClose = { findNavController().navigate(R.id.action_nav_peer_push_to_nav_main) diff --git a/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt b/wallet/src/main/java/net/taler/wallet/settings/SettingsManager.kt @@ -29,11 +29,11 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import net.taler.wallet.R +import net.taler.wallet.main.ViewMode import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.backend.WalletResponse.Error import net.taler.wallet.backend.WalletResponse.Success import net.taler.wallet.balances.BalanceManager -import net.taler.wallet.balances.ScopeInfo import org.json.JSONObject class SettingsManager( @@ -42,23 +42,23 @@ class SettingsManager( private val scope: CoroutineScope, private val balanceManager: BalanceManager, ) { - fun getSelectedScope(c: Context) = c.userPreferencesDataStore.data.map { prefs -> - if (prefs.hasSelectedScope()) { - ScopeInfo.fromPrefs(prefs.selectedScope) + fun getViewMode(c: Context) = c.userPreferencesDataStore.data.map { prefs -> + if (prefs.hasViewMode()) { + ViewMode.fromPrefs(prefs.viewMode) } else { null } } - fun saveSelectedScope(c: Context, scopeInfo: ScopeInfo?) = scope.launch { + fun saveViewMode(c: Context, viewMode: ViewMode?) = scope.launch { c.userPreferencesDataStore.updateData { current -> - if (scopeInfo != null) { + if (viewMode != null) { current.toBuilder() - .setSelectedScope(scopeInfo.toPrefs()) + .setViewMode(viewMode.toPrefs()) .build() } else { current.toBuilder() - .clearSelectedScope() + .clearViewMode() .build() } } @@ -197,7 +197,7 @@ class SettingsManager( is Success -> { withContext(Dispatchers.Main) { Toast.makeText(context, R.string.settings_db_import_success, LENGTH_LONG).show() - balanceManager.loadBalances() + balanceManager.loadAssets(true) } } is Error -> { diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionLossFragment.kt @@ -58,7 +58,6 @@ import net.taler.wallet.transactions.TransactionAction.Suspend import net.taler.wallet.transactions.TransactionMajorState.Pending class TransactionLossFragment: TransactionDetailFragment() { - val scope get() = transactionManager.selectedScope.value override fun onCreateView( inflater: LayoutInflater, diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionManager.kt @@ -28,6 +28,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonPrimitive +import net.taler.wallet.PrefsStateFilter import net.taler.wallet.TAG import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo @@ -51,7 +52,23 @@ enum class TransactionStateFilter { Nonfinal, @SerialName("done") - 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( @@ -61,14 +78,8 @@ class TransactionManager( private val allTransactions = HashMap<ScopeInfo, List<Transaction>>() private val mTransactions = HashMap<ScopeInfo, MutableStateFlow<TransactionsResult>>() private val mSelectedTransaction = MutableStateFlow<Transaction?>(null) - private val mSelectedScope = MutableStateFlow<ScopeInfo?>(null) - private val mSearchQuery = MutableStateFlow<String?>(null) - private val mStateFilter = MutableStateFlow<TransactionStateFilter?>(null) val selectedTransaction = mSelectedTransaction.asStateFlow() - val selectedScope = mSelectedScope.asStateFlow() - val searchQuery = mSearchQuery.asStateFlow() - val stateFilter = mStateFilter.asStateFlow() // This function must be called ONLY when scopeInfo / searchQuery change! // Use remember() {} in Compose to prevent multiple calls during recomposition @@ -77,7 +88,6 @@ class TransactionManager( searchQuery: String? = null, stateFilter: TransactionStateFilter? = null, ): StateFlow<TransactionsResult> { - loadTransactions() return if (scopeInfo != null) { loadTransactions(scopeInfo, searchQuery, stateFilter) mTransactions[scopeInfo]?.asStateFlow() @@ -94,7 +104,7 @@ class TransactionManager( stateFilter: TransactionStateFilter? = null, ) { Log.d(TAG, "loadTransactions($scopeInfo, $searchQuery, $stateFilter)") - val s = scopeInfo ?: mSelectedScope.value ?: run { + val s = scopeInfo ?: run { MutableStateFlow(TransactionsResult.None) return } @@ -195,18 +205,6 @@ class TransactionManager( mSelectedTransaction.value = tx } - fun selectScope( - scopeInfo: ScopeInfo?, - stateFilter: TransactionStateFilter? = null, - ) { - mSelectedScope.value = scopeInfo - mStateFilter.value = stateFilter - } - - fun setSearchQuery(searchQuery: String?) = scope.launch { - mSearchQuery.value = searchQuery - } - fun deleteTransaction(transactionId: String, onError: (it: TalerErrorInfo) -> Unit) = scope.launch { api.request<Unit>("deleteTransaction") { @@ -280,9 +278,9 @@ class TransactionManager( } fun deleteTransactions(transactionIds: List<String>, onError: (it: TalerErrorInfo) -> Unit) { - allTransactions[selectedScope.value]?.filter { transaction -> + allTransactions.values.flatten().filter { transaction -> transaction.transactionId in transactionIds - }?.forEach { toBeDeletedTx -> + }.forEach { toBeDeletedTx -> if (Delete in toBeDeletedTx.txActions) { deleteTransaction(toBeDeletedTx.transactionId) { onError(it) diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt @@ -82,6 +82,7 @@ import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.SelectionModeTopAppBar 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 @@ -110,10 +111,9 @@ import net.taler.wallet.transactions.TransactionStateFilter.* @Composable fun TransactionsComposable( innerPadding: PaddingValues, + viewMode: ViewMode.Transactions, balance: BalanceItem, - currencySpec: CurrencySpecification?, txResult: TransactionsResult, - txStateFilter: TransactionStateFilter?, onTransactionClick: (tx: Transaction) -> Unit, onTransactionsDelete: (txIds: List<String>) -> Unit, onShowBalancesClicked: () -> Unit, @@ -181,12 +181,11 @@ fun TransactionsComposable( item { TransactionsHeader( balance = balance, - spec = currencySpec, onShowBalancesClicked = onShowBalancesClicked, ) } - when (txStateFilter) { + when (viewMode.stateFilter) { Nonfinal -> item { Banner(Modifier.padding(bottom = 6.dp)) { Text( @@ -212,7 +211,7 @@ fun TransactionsComposable( val isSelected = selectedItems.contains(tx.transactionId) TransactionRow( - tx, currencySpec, + tx, balance.available.spec, isSelected = isSelected, selectionMode = selectionMode, onTransactionClick = { @@ -283,7 +282,6 @@ fun ErrorTransactionsComposable(error: TalerErrorInfo) { @Composable fun TransactionsHeader( balance: BalanceItem, - spec: CurrencySpecification?, onShowBalancesClicked: () -> Unit, ) { Row( @@ -300,7 +298,7 @@ fun TransactionsHeader( modifier = Modifier.animateContentSize(), headlineContent = { Text( - getHeaderCurrency(balance, spec), + getHeaderCurrency(balance, balance.available.spec), style = MaterialTheme.typography.titleMedium, ) }, @@ -330,7 +328,7 @@ fun TransactionsHeader( ) Text( - balance.available.withSpec(spec).toString(showSymbol = false), + balance.available.toString(showSymbol = false), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) @@ -538,9 +536,8 @@ fun TransactionsComposableDonePreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - currencySpec = null, + viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(transactions), - txStateFilter = null, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, @@ -573,9 +570,8 @@ fun TransactionsComposablePendingPreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - currencySpec = null, + viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(transactions), - txStateFilter = Nonfinal, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, @@ -590,9 +586,8 @@ fun TransactionsComposableEmptyPreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - currencySpec = null, + viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = Success(listOf()), - txStateFilter = null, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, @@ -607,9 +602,8 @@ fun TransactionsComposableLoadingPreview() { TransactionsComposable( innerPadding = PaddingValues(0.dp), balance = previewBalance, - currencySpec = null, + viewMode = ViewMode.Transactions(previewBalance.scopeInfo), txResult = None, - txStateFilter = null, onTransactionClick = {}, onTransactionsDelete = {}, onShowBalancesClicked = {}, diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/PromptWithdrawFragment.kt @@ -50,6 +50,7 @@ import net.taler.common.Amount import net.taler.common.EventObserver import net.taler.wallet.MainViewModel import net.taler.wallet.R +import net.taler.wallet.main.ViewMode import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.balances.ScopeInfo @@ -90,7 +91,9 @@ class PromptWithdrawFragment: Fragment() { val withdrawExchangeUri = arguments?.getString("withdrawExchangeUri") val exchangeBaseUrl = arguments?.getString("exchangeBaseUrl") val amount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } - val scope: ScopeInfo? = arguments?.getString("scopeInfo")?.let { BackendManager.json.decodeFromString(it) } + val scope: ScopeInfo? = arguments?.getString("scopeInfo")?.let { + BackendManager.json.decodeFromString(it) + } ?: (model.viewMode.value as? ViewMode.Transactions)?.selectedScope editableCurrency = arguments?.getBoolean("editableCurrency") ?: true val scopes = balanceManager.getScopes() @@ -106,7 +109,6 @@ class PromptWithdrawFragment: Fragment() { val defaultScope = scope ?: status.scopeInfo - ?: transactionManager.selectedScope.value ?: scopes.firstOrNull() LaunchedEffect(status.status) { @@ -183,7 +185,7 @@ class PromptWithdrawFragment: Fragment() { findNavController().navigate(R.id.action_global_reviewExchangeTos, args) }, onConfirm = { age -> - exchange?.scopeInfo?.let { model.transactionManager.selectScope(it) } + exchange?.scopeInfo?.let { model.selectScope(it) } withdrawManager.acceptWithdrawal(age) }, ) @@ -224,7 +226,7 @@ class PromptWithdrawFragment: Fragment() { } else return@let if (transactionManager.selectTransaction(it)) { - status.amountInfo?.scopeInfo?.let { s -> transactionManager.selectScope(s) } + status.amountInfo?.scopeInfo?.let { s -> model.selectScope(s) } findNavController().navigate(R.id.action_promptWithdraw_to_nav_transactions_detail_withdrawal) } else { findNavController().navigate(R.id.action_promptWithdraw_to_nav_main) @@ -243,7 +245,7 @@ class PromptWithdrawFragment: Fragment() { withdrawManager.withdrawStatus.collect { status -> when (status.status) { TosReviewRequired -> { - if (!acceptingTos && transactionManager.selectedScope.value != null) { + if (!acceptingTos && model.viewMode.value is ViewMode.Transactions) { acceptingTos = true val args = bundleOf("exchangeBaseUrl" to status.exchangeBaseUrl) findNavController().navigate(R.id.action_global_reviewExchangeTos, args) diff --git a/wallet/src/main/proto/user_prefs.proto b/wallet/src/main/proto/user_prefs.proto @@ -15,9 +15,31 @@ message PrefsScopeInfo { optional string url = 3; } +enum PrefsStateFilter { + NONE = 0; + FINAL = 1; + NONFINAL = 2; + DONE = 3; +} + +enum ViewModeType { + ASSETS = 0; + TRANSACTIONS = 1; +} + +message PrefsViewMode { + ViewModeType type = 1; + + // TRANSACTIONS + optional PrefsScopeInfo selectedScope = 2; + optional string searchQuery = 3; + optional PrefsStateFilter stateFilter = 4; +} + message UserPreferences { optional PrefsScopeInfo selectedScope = 1; optional bool actionButtonUsed = 2; optional bool devModeEnabled = 3; optional bool biometricLockEnabled = 4; + optional PrefsViewMode viewMode = 5; } \ No newline at end of file diff --git a/wallet/src/main/res/drawable/ic_donau.xml b/wallet/src/main/res/drawable/ic_donau.xml @@ -0,0 +1,29 @@ +<!-- + ~ 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/> + --> + +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportHeight="960" + android:viewportWidth="960" + android:width="24dp"> + + <path + android:fillColor="@android:color/white" + android:pathData="M557,442L387,272L444,216L557,329L784,103L840,159L557,442ZM320,740L598,816L836,742Q831,733 821.5,726.5Q812,720 800,720L598,720Q571,720 555,718Q539,716 522,710L429,679L451,601L532,628Q549,633 572,636Q595,639 640,640L640,640Q640,640 640,640Q640,640 640,640Q640,629 633.5,619Q627,609 618,606L384,520Q384,520 384,520Q384,520 384,520L320,520L320,740ZM80,880L80,440L384,440Q391,440 398,441.5Q405,443 411,445L646,532Q679,544 699.5,574Q720,604 720,640L800,640Q850,640 885,673Q920,706 920,760L920,800L600,900L320,822L320,822L320,880L80,880ZM160,800L240,800L240,520L160,520L160,800Z"/> + +</vector> diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -23,7 +23,7 @@ <fragment android:id="@+id/nav_main" android:name="net.taler.wallet.MainFragment" - android:label="@string/balances_title"> + android:label="@string/assets_title"> <action android:id="@+id/action_nav_main_to_nav_uri_input" app:destination="@id/nav_uri_input" /> @@ -101,6 +101,21 @@ android:label="@string/exchange_list_title" /> <fragment + android:id="@+id/nav_settings_donau" + android:name="net.taler.wallet.donau.SetDonauFragment" + android:label="@string/donau_title" /> + + <fragment + android:id="@+id/nav_donau_statement" + android:name="net.taler.wallet.donau.DonauStatementFragment" + android:label="@string/donau_statement_title"> + <argument + android:name="donationStatementSig" + app:argType="string" + app:nullable="false" /> + </fragment> + + <fragment android:id="@+id/nav_wire_transfer_details" android:name="net.taler.wallet.transfer.WireTransferDetailsFragment" android:label="@string/withdraw_manual_ready_details_intro"> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -73,6 +73,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="paste">Paste</string> <string name="paste_invalid">Clipboard contains an invalid data type</string> <string name="reset">Reset</string> + <string name="save">Save</string> <string name="share_payment">Share payment link</string> <string name="uri_invalid">Not a valid Taler URI</string> <string name="warning">Warning</string> @@ -122,6 +123,12 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="amount_transfer">Transfer</string> <string name="amount_withdraw">Amount to withdraw from bank</string> + <!-- Assets --> + + <string name="assets_section_balances">Balances</string> + <string name="assets_section_statements">Donations</string> + <string name="assets_title">Assets</string> + <!-- Balances --> <string name="balance_scope_auditor">Auditor: %1$s</string> @@ -131,7 +138,6 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="balances_empty_get_money">Get demo money</string> <string name="balances_inbound_amount">+%1$s incoming</string> <string name="balances_outbound_amount">-%1$s outgoing</string> - <string name="balances_title">Balances</string> <!-- Transactions --> @@ -195,6 +201,25 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="transactions_suspend">Suspend</string> <string name="transactions_title">Transactions</string> + <!-- Donau --> + + <string name="donau_id">Donor tax ID</string> + <string name="donau_ready">Donations have been configured successfully</string> + <string name="donau_select_button">Select tax authority</string> + <string name="donau_selector_confirm">Setup tax payer ID</string> + <string name="donau_selector_title">Select tax authority</string> + <string name="donau_statement_instruction">Show this QR code to your tax authority to verify this donation statement</string> + <string name="donau_statement_legal_domain">Legal domain</string> + <string name="donau_statement_tax_authority">Tax authority</string> + <string name="donau_statement_title">Donation statement</string> + <string name="donau_statement_title_year">Donations %1$d</string> + <string name="donau_statement_year">Year</string> + <string name="donau_title">Tax-deductible donations</string> + <string name="donau_toggle_label">Receive tax-deductible donation receipt</string> + <string name="donau_url">Donation authority URL</string> + <string name="donau_warning_mismatch">Your configured tax authority (%1$s) is not available for this option. If you moved, please select the tax authority that applies to your new jurisdiction.</string> + <string name="donau_warning_unset">No tax authority is configured in this wallet, please select one that applies to your jurisdiction.</string> + <!-- Payments --> <string name="payment_aborted">Aborted</string> @@ -428,6 +453,8 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="settings_dev_mode_summary">Shows more information intended for debugging</string> <string name="settings_dialog_import_message">This operation will overwrite your existing database. Do you want to continue?</string> <string name="settings_dialog_reset_message">Do you really want to reset the wallet and lose all coins and purchases?</string> + <string name="settings_donau">Tax-deductible donations</string> + <string name="settings_donau_summary">Set donation authority and donor tax ID</string> <string name="settings_lock_auth">Protect access to wallet</string> <string name="settings_lock_auth_summary">Require fingerprint or password to access the wallet</string> <string name="settings_logcat">Debug log</string> diff --git a/wallet/src/main/res/xml/settings_main.xml b/wallet/src/main/res/xml/settings_main.xml @@ -24,6 +24,13 @@ app:summary="@string/exchange_settings_summary" app:title="@string/exchange_settings_title" /> + <Preference + app:fragment="net.taler.wallet.donau.SetDonauFragment" + app:icon="@drawable/ic_donau" + app:key="pref_donau" + app:summary="@string/settings_donau_summary" + app:title="@string/settings_donau" /> + <SwitchPreference app:icon="@drawable/ic_shield" app:key="pref_biometric_lock"