taler-android

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

commit 1865f862551ecf680b2f88e43e974f7774140bd2
parent cf2c667631d5ab04b2bac5f4f7e556f80b27c9c5
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 28 Nov 2024 17:14:59 +0100

[wallet] new deposit + known bank accounts flow

Diffstat:
Mwallet/build.gradle | 2+-
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/accounts/AccountManager.kt | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Awallet/src/main/java/net/taler/wallet/accounts/Accounts.kt | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/accounts/AddAccountBitcoin.kt | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/accounts/AddAccountComposable.kt | 337+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/accounts/AddAccountFragment.kt | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/accounts/AddAccountIBAN.kt | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/accounts/AddAccountTaler.kt | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dwallet/src/main/java/net/taler/wallet/accounts/KnownBankAccounts.kt | 126-------------------------------------------------------------------------------
Awallet/src/main/java/net/taler/wallet/compose/Avatar.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mwallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt | 95++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mwallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mwallet/src/main/java/net/taler/wallet/deposit/DepositState.kt | 5+++--
Dwallet/src/main/java/net/taler/wallet/deposit/MakeDepositBitcoin.kt | 70----------------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt | 266++++++++++++-------------------------------------------------------------------
Dwallet/src/main/java/net/taler/wallet/deposit/MakeDepositIBAN.kt | 90-------------------------------------------------------------------------------
Dwallet/src/main/java/net/taler/wallet/deposit/MakeDepositTaler.kt | 132-------------------------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt | 19+++++++++++++------
Mwallet/src/main/java/net/taler/wallet/peer/PeerManager.kt | 11++++++++++-
Mwallet/src/main/res/navigation/nav_graph.xml | 22++++++++++++++++++++++
Mwallet/src/main/res/values/strings.xml | 14++++++++++++++
24 files changed, 1781 insertions(+), 758 deletions(-)

diff --git a/wallet/build.gradle b/wallet/build.gradle @@ -23,7 +23,7 @@ plugins { id "com.google.protobuf" version "0.9.4" } -def qtart_version = "0.13.13" +def qtart_version = "0.14.0" static def versionCodeEpoch() { return (new Date().getTime() / 1000).toInteger() diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -122,7 +122,7 @@ class MainViewModel( val peerManager: PeerManager = PeerManager(api, exchangeManager, viewModelScope) val settingsManager: SettingsManager = SettingsManager(app.applicationContext, api, viewModelScope, balanceManager) val accountManager: AccountManager = AccountManager(api, viewModelScope) - val depositManager: DepositManager = DepositManager(api, viewModelScope) + val depositManager: DepositManager = DepositManager(api, viewModelScope, balanceManager) private val mTransactionsEvent = MutableLiveData<Event<ScopeInfo>>() val transactionsEvent: LiveData<Event<ScopeInfo>> = mTransactionsEvent diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AccountManager.kt b/wallet/src/main/java/net/taler/wallet/accounts/AccountManager.kt @@ -16,39 +16,92 @@ package net.taler.wallet.accounts +import android.util.Log +import androidx.annotation.UiThread 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.accounts.ListBankAccountsResult.Error +import net.taler.wallet.accounts.ListBankAccountsResult.None +import net.taler.wallet.accounts.ListBankAccountsResult.Success +import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi +import org.json.JSONArray class AccountManager( private val api: WalletBackendApi, private val scope: CoroutineScope, ) { + private val mBankAccounts = MutableStateFlow<ListBankAccountsResult>(None) + internal val bankAccounts = mBankAccounts.asStateFlow() - fun listKnownBankAccounts() { - scope.launch { - val response = api.request("listKnownBankAccounts", KnownBankAccounts.serializer()) - response.onError { - throw AssertionError("Wallet core failed to return known bank accounts!") - }.onSuccess { knownBankAccounts -> + @UiThread + fun listBankAccounts(currency: String? = null) = scope.launch { + mBankAccounts.value = None + api.request("listBankAccounts", ListBankAccountsResponse.serializer()) { + if (currency != null) put("currency", currency) + this + }.onError { error -> + Log.e(TAG, "Error listKnownBankAccounts $error") + mBankAccounts.value = Error(error) + }.onSuccess { response -> + mBankAccounts.value = Success( + accounts = response.accounts, + currency = currency, + ) + } + } - } + suspend fun addBankAccount( + paytoUri: String, + label: String, + currencies: List<String>? = null, + replaceBankAccountId: String? = null, + onError: (error: TalerErrorInfo) -> Unit, + ) { + api.request<Unit>("addBankAccount") { + currencies?.let { put("currencies", JSONArray(it)) } + replaceBankAccountId?.let { put("replaceBankAccountId", it) } + put("paytoUri", paytoUri) + put("label", label) + }.onError { error -> + Log.e(TAG, "Error addKnownBankAccount $error") + onError(error) + }.onSuccess { + listBankAccounts() } } - fun addKnownBankAccount(paytoUri: String, alias: String, currency: String) { - scope.launch { - val response = api.request<Unit>("addKnownBankAccounts") { - put("payto", paytoUri) - put("alias", alias) - put("currency", currency) - } - response.onError { - throw AssertionError("Wallet core failed to add known bank account!") - }.onSuccess { - - } + fun forgetBankAccount( + id: String, + onError: (error: TalerErrorInfo) -> Unit, + ) = scope.launch { + api.request<Unit>("forgetBankAccount") { + put("bankAccountId", id) + }.onError { error -> + Log.e(TAG, "Error addKnownBankAccount $error") + onError(error) + }.onSuccess { + listBankAccounts() } } + suspend fun getBankAccountById( + id: String, + onError: (error: TalerErrorInfo) -> Unit, + ): KnownBankAccountInfo? { + var response: KnownBankAccountInfo? = null + api.request("getBankAccountById", KnownBankAccountInfo.serializer()) { + put("bankAccountId", id) + }.onError { error -> + Log.e(TAG, "Error getBankAccountById $error") + onError(error) + }.onSuccess { + response = it + } + + return response + } } diff --git a/wallet/src/main/java/net/taler/wallet/accounts/Accounts.kt b/wallet/src/main/java/net/taler/wallet/accounts/Accounts.kt @@ -0,0 +1,228 @@ +/* + * This file is part of GNU Taler + * (C) 2022 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.accounts + +import android.net.Uri +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +import net.taler.common.Bech32 +import net.taler.wallet.backend.TalerErrorInfo + +@Serializable +data class KnownBankAccountInfo( + val bankAccountId: String, + val paytoUri: String, + + /** + * Did we previously complete a KYC process for this bank account? + */ + val kycCompleted: Boolean, + + /** + * Currencies supported by the bank, if known. + */ + val currencies: List<String>? = null, + + val label: String? = null, +) + +@Serializable +sealed class ListBankAccountsResult { + @Serializable + data object None: ListBankAccountsResult() + + @Serializable + data class Success( + val accounts: List<KnownBankAccountInfo>, + val currency: String?, + ): ListBankAccountsResult() + + @Serializable + data class Error(val error: TalerErrorInfo): ListBankAccountsResult() +} + +@Serializable +data class ListBankAccountsResponse( + val accounts: List<KnownBankAccountInfo>, +) + +@Serializable +data class AddBankAccountResponse( + val bankAccountId: String, +) + +@Serializable +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("targetType") +sealed class PaytoUri( + val isKnown: Boolean, + val targetType: String, +) { + abstract val targetPath: String + abstract val params: Map<String, String> + abstract val receiverName: String? + + companion object { + fun parse(paytoUri: String): PaytoUri? { + val uri = Uri.parse(paytoUri) + if (uri.scheme != "payto") return null + if (uri.pathSegments.isEmpty()) return null + return when (uri.authority?.lowercase()) { + "iban" -> PaytoUriIban.fromString(uri) + "x-taler-bank" -> PaytoUriTalerBank.fromString(uri) + "bitcoin" -> PaytoUriBitcoin.fromString(uri) + else -> null + } + } + } +} + +@Serializable +@SerialName("iban") +data class PaytoUriIban( + val iban: String, + val bic: String? = "SANDBOXX", + override val targetPath: String, + override val params: Map<String, String>, + override val receiverName: String?, +) : PaytoUri( + isKnown = true, + targetType = "iban", +) { + val paytoUri: String + get() = Uri.Builder() + .scheme("payto") + .authority(targetType) + .apply { if (bic != null) appendPath(bic) } + .appendPath(iban) + .apply { + params.forEach { (key, value) -> + appendQueryParameter(key, value) + } + } + .build().toString() + + companion object { + fun fromString(uri: Uri): PaytoUriIban? { + return PaytoUriIban( + iban = uri.lastPathSegment ?: return null, + bic = if (uri.pathSegments.size > 1) { + uri.pathSegments.first() ?: return null + } else null, + params = uri.queryParametersMap, + receiverName = uri.getQueryParameter("receiver-name"), + targetPath = "", + ) + } + } +} + +@Serializable +@SerialName("x-taler-bank") +data class PaytoUriTalerBank( + val host: String, + val account: String, + override val targetPath: String, + override val params: Map<String, String>, + override val receiverName: String?, +) : PaytoUri( + isKnown = true, + targetType = "x-taler-bank", +) { + val paytoUri: String + get() = Uri.Builder() + .scheme("payto") + .authority(targetType) + .appendPath(host) + .appendPath(account) + .apply { + params.forEach { (key, value) -> + appendQueryParameter(key, value) + } + } + .build().toString() + + companion object { + fun fromString(uri: Uri): PaytoUriTalerBank? { + return PaytoUriTalerBank( + host = uri.pathSegments.getOrNull(0) ?: return null, + account = uri.pathSegments.getOrNull(1) ?: return null, + params = uri.queryParametersMap, + receiverName = uri.getQueryParameter("receiver-name"), + targetPath = "", + ) + } + } +} + +@Serializable +@SerialName("bitcoin") +data class PaytoUriBitcoin( + @SerialName("segwitAddrs") + val segwitAddresses: List<String>, + override val targetPath: String, + override val params: Map<String, String> = emptyMap(), + override val receiverName: String?, +) : PaytoUri( + isKnown = true, + targetType = "bitcoin", +) { + val paytoUri: String + get() = Uri.Builder() + .scheme("payto") + .authority(targetType) + .apply { + segwitAddresses.forEach { address -> + appendPath(address) + } + } + .apply { + params.forEach { (key, value) -> + appendQueryParameter(key, value) + } + } + .build().toString() + + companion object { + fun fromString(uri: Uri): PaytoUriBitcoin? { + val msg = uri.getQueryParameter("message").orEmpty() + val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg) + val reserve = reg?.value + ?: uri.getQueryParameter("subject") + ?: return null + val segwitAddresses = Bech32.generateFakeSegwitAddress( + reservePub = reserve, + addr = uri.pathSegments.firstOrNull() + ?: return null, + ) + + return PaytoUriBitcoin( + segwitAddresses = segwitAddresses, + params = uri.queryParametersMap, + receiverName = uri.getQueryParameter("receiver-name"), + targetPath = "", + ) + } + } +} + +val Uri.queryParametersMap: Map<String, String> + get() = queryParameterNames.mapNotNull { name -> + getQueryParameter(name)?.let { name to it } + }.toMap() +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt b/wallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt @@ -0,0 +1,351 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.accounts + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CurrencyBitcoin +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.accounts.ListBankAccountsResult.Error +import net.taler.wallet.accounts.ListBankAccountsResult.None +import net.taler.wallet.accounts.ListBankAccountsResult.Success +import net.taler.wallet.compose.Avatar +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.showError +import net.taler.wallet.withdraw.WithdrawalError + +class BankAccountsFragment: Fragment() { + private val model: MainViewModel by activityViewModels() + private var currency: String? = null + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + currency = arguments?.getString("currency") + + setContent { + val accounts by model.accountManager.bankAccounts.collectAsState() + + TalerSurface { + Scaffold( + floatingActionButton = { + val tooltipState = rememberTooltipState() + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(stringResource(R.string.send_deposit_account_add)) } }, + state = tooltipState, + ) { + FloatingActionButton(onClick = { + findNavController().navigate(R.id.action_nav_bank_accounts_to_add_bank_account) + }) { + Icon(Icons.Default.Add, contentDescription = null) + } + } + }, + contentWindowInsets = WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) { innerPadding -> + when (val acc = accounts) { + is None -> LoadingScreen() + is Success -> BankAccountsList( + innerPadding, + acc.accounts, + onEdit = { account -> + // TODO: navigate + val args = bundleOf("bankAccountId" to account.bankAccountId) + findNavController().navigate(R.id.action_nav_bank_accounts_to_add_bank_account, args) + }, + onForget = { account -> + model.accountManager.forgetBankAccount(account.bankAccountId) { + showError(it) + } + }, + ) + is Error -> WithdrawalError(acc.error) + } + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + model.accountManager.listBankAccounts(currency) + } + } + } +} + +@Composable +fun BankAccountsList( + innerPadding: PaddingValues, + accounts: List<KnownBankAccountInfo>, + onEdit: (account: KnownBankAccountInfo) -> Unit, + onForget: (account: KnownBankAccountInfo) -> Unit, +) { + var showDeleteDialog by remember { mutableStateOf(false) } + var accountToDelete by remember { mutableStateOf<KnownBankAccountInfo?>(null) } + + if (showDeleteDialog) AlertDialog( + title = { Text(stringResource(R.string.send_deposit_account_forget_dialog_title)) }, + text = { Text(stringResource(R.string.send_deposit_account_forget_dialog_message)) }, + onDismissRequest = { showDeleteDialog = false }, + confirmButton = { + TextButton(onClick = { + accountToDelete?.let(onForget) + accountToDelete = null + showDeleteDialog = false + }) { + Text(stringResource(R.string.transactions_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteDialog = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + + if (accounts.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding( + vertical = 32.dp, + horizontal = 16.dp, + ), + text = stringResource(R.string.send_deposit_known_bank_accounts_empty), + ) + } + return + } + + LazyColumn( + modifier = Modifier + .consumeWindowInsets(innerPadding) + .fillMaxSize() + ) { + items(accounts, key = { it.paytoUri }) { account -> + BankAccountRow(account, + onForget = { + accountToDelete = account + showDeleteDialog = true + }, + onClick = { onEdit(account) }, + ) + + } + } +} + +@Composable +fun BankAccountRow( + account: KnownBankAccountInfo, + showMenu: Boolean = true, + onClick: (() -> Unit)? = null, + onForget: (() -> Unit)? = null, +) { + val paytoUri = remember(account.paytoUri) { + PaytoUri.parse(account.paytoUri) + } + + ListItem( + modifier = Modifier.then( + onClick?.let { + Modifier.clickable { onClick() } + } ?: Modifier + ), + leadingContent = { + Avatar { + Icon( + when(paytoUri) { + is PaytoUriTalerBank -> Icons.Default.Dns + is PaytoUriBitcoin -> Icons.Default.CurrencyBitcoin + else -> Icons.Default.AccountBalance + }, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + contentDescription = null, + ) + } + }, + overlineContent = { + when(paytoUri) { + is PaytoUriIban -> Text(stringResource(R.string.send_deposit_iban)) + is PaytoUriTalerBank -> Text(stringResource(R.string.send_deposit_taler)) + is PaytoUriBitcoin -> Text(stringResource(R.string.send_deposit_bitcoin)) + else -> {} + } + }, + headlineContent = { + Text(account.label + ?: stringResource(R.string.send_deposit_no_alias)) + }, + supportingContent = { + when(paytoUri) { + is PaytoUriIban -> Text(paytoUri.iban) + is PaytoUriTalerBank -> Text(paytoUri.account) + is PaytoUriBitcoin -> { + Text(remember(paytoUri.segwitAddresses) { + paytoUri.segwitAddresses.joinToString(" ") + }) + } + else -> {} + } + }, + trailingContent = { + // TODO: turn into dropdown menu if more options are added + if (showMenu) IconButton(onClick = { onForget?.let { it() } }) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.send_deposit_known_bank_account_delete), + ) + } + } + ) +} + +val previewKnownAccounts = listOf( + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriIban( + iban = "DE7489694250801", + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + ).paytoUri, + kycCompleted = true, + currencies = listOf("KUDOS"), + label = "GLS", + ), + + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriTalerBank( + host = "bank.test.taler.net", + account = "john123", + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + ).paytoUri, + kycCompleted = true, + currencies = listOf("TESTKUDOS"), + label = "Main on test", + ), + + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriBitcoin( + segwitAddresses = listOf("bc1qkrnmwd8t4yxzpha8gk3w8h8lyecfp2ra9yvgf9"), + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + ).paytoUri, + kycCompleted = true, + currencies = listOf("BTC"), + label = "Android wallet", + ), +) + +@Preview +@Composable +fun KnownAccountsListPreview() { + TalerSurface { + BankAccountsList( + innerPadding = PaddingValues(0.dp), + accounts = previewKnownAccounts, + onEdit = {}, + onForget = {}, + ) + } +} + +@Preview +@Composable +fun KnownAccountsListEmptyPreview() { + TalerSurface { + BankAccountsList( + innerPadding = PaddingValues(0.dp), + accounts = listOf(), + onEdit = {}, + onForget = {}, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddAccountBitcoin.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddAccountBitcoin.kt @@ -0,0 +1,67 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.deposit + +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.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import net.taler.wallet.R + +@Composable +fun AddAccountBitcoin( + bitcoinAddress: String, + onFormEdited: (bitcoinAddress: String) -> Unit, +) { + val focusManager = LocalFocusManager.current + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ), + value = bitcoinAddress, + singleLine = true, + onValueChange = { input -> + onFormEdited(input) + }, + isError = bitcoinAddress.isBlank(), + label = { + Text( + stringResource(R.string.send_deposit_bitcoin_address), + color = if (bitcoinAddress.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), + ) +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddAccountComposable.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddAccountComposable.kt @@ -0,0 +1,337 @@ +/* + * This file is part of GNU Taler + * (C) 2023 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.deposit + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.wallet.BottomInsetsSpacer +import net.taler.wallet.R +import net.taler.wallet.accounts.KnownBankAccountInfo +import net.taler.wallet.accounts.PaytoUri +import net.taler.wallet.accounts.PaytoUriBitcoin +import net.taler.wallet.accounts.PaytoUriIban +import net.taler.wallet.accounts.PaytoUriTalerBank +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.WarningLabel +import net.taler.wallet.peer.OutgoingError +import net.taler.wallet.peer.PeerErrorComposable +import net.taler.wallet.useDebounce + +@Composable +fun AddAccountComposable( + presetAccount: KnownBankAccountInfo? = null, + depositWireTypes: GetDepositWireTypesResponse, + validateIban: suspend (iban: String) -> Boolean, + onSubmit: (paytoUri: String, label: String) -> Unit, + onClose: () -> Unit, +) { + val focusManager = LocalFocusManager.current + val supportedWireTypes = remember(depositWireTypes) { depositWireTypes.wireTypes } + val talerBankHostnames = remember(depositWireTypes) { depositWireTypes.hostNames } + val presetPaytoUri = remember(presetAccount) { presetAccount?.let { PaytoUri.parse(it.paytoUri) } } + + if (supportedWireTypes.isEmpty()) { + return AddAccountErrorComposable( + message = stringResource(R.string.send_deposit_no_methods_error), + onClose = onClose, + ) + } + + var selectedWireType by remember(supportedWireTypes, presetAccount) { + if (presetAccount == null) { + return@remember mutableStateOf(supportedWireTypes.firstOrNull()) + } + + val parsed = PaytoUri.parse(presetAccount.paytoUri) + mutableStateOf(when (parsed) { + is PaytoUriIban -> WireType.IBAN + is PaytoUriTalerBank -> WireType.TalerBank + is PaytoUriBitcoin -> WireType.Bitcoin + else -> supportedWireTypes.firstOrNull() + }) + } + + // payto:// stuff + var formError by rememberSaveable { mutableStateOf(false) } // TODO: do an initial validation! + var ibanError by rememberSaveable(presetPaytoUri) { mutableStateOf(presetPaytoUri == null) } + var formAlias by rememberSaveable(presetAccount) { mutableStateOf(presetAccount?.label ?: "") } + var ibanName by rememberSaveable(presetPaytoUri) { mutableStateOf((presetPaytoUri as? PaytoUriIban)?.receiverName ?: "") } + var ibanIban by rememberSaveable(presetPaytoUri) { mutableStateOf((presetPaytoUri as? PaytoUriIban)?.iban ?: "") } + var talerName by rememberSaveable(presetPaytoUri) { mutableStateOf((presetPaytoUri as? PaytoUriTalerBank)?.receiverName ?: "") } + var talerHost by rememberSaveable(presetPaytoUri) { mutableStateOf((presetPaytoUri as? PaytoUriTalerBank)?.host ?: talerBankHostnames.firstOrNull() ?: "") } + var talerAccount by rememberSaveable(presetPaytoUri) { mutableStateOf((presetPaytoUri as? PaytoUriTalerBank)?.account ?: "") } + var bitcoinAddress by rememberSaveable(presetPaytoUri) { mutableStateOf("") } // TODO: fill-in bitcoin address + + val paytoUri = when(selectedWireType) { + WireType.IBAN -> getIbanPayto(ibanName, ibanIban) + WireType.TalerBank -> getTalerPayto(talerName, talerHost, talerAccount) + WireType.Bitcoin -> getBitcoinPayto(bitcoinAddress) + else -> null + } + + // form validation + paytoUri.useDebounce { + formError = when (selectedWireType) { + WireType.IBAN -> { + val valid = validateIban(ibanIban) + ibanError = !valid || ibanIban.isBlank() + !valid || ibanName.isBlank() + } + + WireType.TalerBank -> { + talerName.isBlank() + || talerHost.isBlank() + || talerAccount.isBlank() + } + + WireType.Bitcoin -> { + bitcoinAddress.isBlank() + } + + else -> true + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + horizontalAlignment = CenterHorizontally, + ) { + // Do not show chooser when editing account + if (presetAccount == null + && selectedWireType != null + && supportedWireTypes.size > 1) { + item { + MakeDepositWireTypeChooser( + supportedWireTypes = supportedWireTypes, + selectedWireType = selectedWireType!!, + onSelectWireType = { + selectedWireType = it + } + ) + } + } + + item { + WarningLabel( + modifier = Modifier.padding(16.dp), + label = stringResource(R.string.send_deposit_account_warning), + ) + } + + when(selectedWireType) { + WireType.IBAN -> item { + AddAccountIBAN( + name = ibanName, + iban = ibanIban, + ibanError = ibanError, + onFormEdited = { name, iban -> + ibanName = name + ibanIban = iban + } + ) + } + + WireType.TalerBank -> item { + AddAccountTaler( + name = talerName, + host = talerHost, + account = talerAccount, + supportedHosts = talerBankHostnames, + onFormEdited = { name, host, account -> + talerName = name + talerHost = host + talerAccount = account + } + ) + } + + WireType.Bitcoin -> item { + AddAccountBitcoin( + bitcoinAddress = bitcoinAddress, + onFormEdited = { address -> + bitcoinAddress = address + } + ) + } + + else -> {} + } + + item { + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = formAlias, + onValueChange = { + formAlias = it + }, + label = { + Text(stringResource(R.string.send_deposit_account_note)) + }, + singleLine = true, + isError = formAlias.isBlank(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + } + + item { + Button( + modifier = Modifier.padding(16.dp), + enabled = !formError && formAlias.isNotBlank(), + onClick = { + focusManager.clearFocus() + if (paytoUri != null && formAlias.isNotEmpty()) { + onSubmit(paytoUri, formAlias) + } + }, + ) { + Icon( + if (presetAccount == null) { + Icons.Default.Add + } else { + Icons.Default.Edit + }, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + + if (presetAccount == null) { + Text(stringResource(R.string.send_deposit_account_add)) + } else { + Text(stringResource(R.string.send_deposit_account_edit)) + } + } + } + + item { + BottomInsetsSpacer() + } + } +} + +@Composable +fun MakeDepositWireTypeChooser( + modifier: Modifier = Modifier, + supportedWireTypes: List<WireType>, + selectedWireType: WireType, + onSelectWireType: (wireType: WireType) -> Unit, +) { + val selectedIndex = supportedWireTypes.indexOfFirst { + it == selectedWireType + } + + ScrollableTabRow( + selectedTabIndex = selectedIndex, + modifier = modifier, + edgePadding = 8.dp, + ) { + supportedWireTypes.forEach { wireType -> + if (wireType != WireType.Unknown) { + Tab( + selected = selectedWireType == wireType, + onClick = { onSelectWireType(wireType) }, + text = { + Text(when(wireType) { + WireType.IBAN -> stringResource(R.string.send_deposit_iban) + WireType.TalerBank -> stringResource(R.string.send_deposit_taler) + WireType.Bitcoin -> stringResource(R.string.send_deposit_bitcoin) + else -> error("unknown method") + }) + } + ) + } + } + } +} + +@Composable +fun AddAccountErrorComposable( + message: String, + onClose: () -> Unit, +) { + PeerErrorComposable( + state = OutgoingError(info = TalerErrorInfo( + message = message, + code = TalerErrorCode.UNKNOWN, + )), + onClose = onClose, + ) +} + +@Preview +@Composable +fun PreviewAddAccountComposable() { + Surface { + AddAccountComposable( + depositWireTypes = GetDepositWireTypesResponse( + wireTypeDetails = listOf( + WireTypeDetails( + paymentTargetType = WireType.IBAN, + talerBankHostnames = listOf("bank.test.taler.net") + ), + WireTypeDetails( + paymentTargetType = WireType.TalerBank, + talerBankHostnames = listOf("bank.test.taler.net") + ), + WireTypeDetails( + paymentTargetType = WireType.Bitcoin, + talerBankHostnames = emptyList(), + ) + ), + ), + validateIban = { true }, + onSubmit = { _, _ -> }, + onClose = {}, + ) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddAccountFragment.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddAccountFragment.kt @@ -0,0 +1,107 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.accounts + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.LoadingScreen +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.deposit.AddAccountComposable +import net.taler.wallet.deposit.GetDepositWireTypesResponse +import net.taler.wallet.showError + +class AddAccountFragment: Fragment() { + private val model: MainViewModel by activityViewModels() + private val accountManager by lazy { model.accountManager } + private val depositManager by lazy { model.depositManager } + private var bankAccountId: String? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + bankAccountId = arguments?.getString("bankAccountId") + + val supportActionBar = (requireActivity() as? AppCompatActivity)?.supportActionBar + if (bankAccountId == null) { + supportActionBar?.setTitle(R.string.send_deposit_account_add) + } else { + supportActionBar?.setTitle(R.string.send_deposit_account_edit) + } + + setContent { + TalerSurface { + var depositWireTypes by remember { mutableStateOf<GetDepositWireTypesResponse?>(null) } + var bankAccount by remember { mutableStateOf<KnownBankAccountInfo?>(null) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(bankAccountId) { + if (bankAccountId == null) return@LaunchedEffect + bankAccount = accountManager.getBankAccountById(bankAccountId!!) { error -> + showError(error) + } + } + + LaunchedEffect(Unit) { + depositWireTypes = depositManager.getDepositWireTypes() + } + + if (depositWireTypes == null || (bankAccountId != null && bankAccount == null)) { + LoadingScreen() + } else { + AddAccountComposable( + presetAccount = bankAccount, + depositWireTypes = depositWireTypes!!, + validateIban = depositManager::validateIban, + onSubmit = { paytoUri, label -> + coroutineScope.launch { + accountManager.addBankAccount( + paytoUri = paytoUri, + label = label, + replaceBankAccountId = bankAccountId, + ) { + showError(it) + } + // TODO: should we return on error? + findNavController().popBackStack() + } + }, + onClose = { + findNavController().popBackStack() + }, + ) + } + } + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddAccountIBAN.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddAccountIBAN.kt @@ -0,0 +1,99 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.deposit + +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.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import net.taler.wallet.R + +@Composable +fun AddAccountIBAN( + name: String, + iban: String, + ibanError: Boolean, + onFormEdited: (name: String, iban: String) -> Unit +) { + val focusManager = LocalFocusManager.current + OutlinedTextField( + modifier = Modifier + .padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ).fillMaxWidth(), + value = name, + onValueChange = { input -> + onFormEdited(input, iban) + }, + singleLine = true, + isError = name.isBlank(), + label = { + Text( + stringResource(R.string.send_deposit_name), + color = if (name.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), + ) + + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = iban, + singleLine = true, + onValueChange = { input -> + onFormEdited(name, input.uppercase()) + }, + isError = ibanError, + supportingText = { + if (ibanError) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.send_deposit_iban_error), + color = MaterialTheme.colorScheme.error + ) + } + }, + label = { + Text( + text = stringResource(R.string.send_deposit_iban), + color = if (ibanError) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), + ) +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AddAccountTaler.kt b/wallet/src/main/java/net/taler/wallet/accounts/AddAccountTaler.kt @@ -0,0 +1,142 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.deposit + +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.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import net.taler.wallet.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddAccountTaler( + supportedHosts: List<String>, + name: String, + host: String, + account: String, + onFormEdited: (name: String, host: String, account: String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + modifier = Modifier + .padding( + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ) + .fillMaxWidth() + .menuAnchor(), + readOnly = true, + value = host, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + onValueChange = {}, + label = { + Text( + stringResource(R.string.send_deposit_host), + color = if (host.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + }, + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + supportedHosts.forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + onFormEdited(name, it, account) + expanded = false + }, + ) + } + } + } + + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = name, + onValueChange = { input -> + onFormEdited(input, host, account) + }, + singleLine = true, + isError = name.isBlank(), + label = { + Text( + stringResource(R.string.send_deposit_name), + color = if (name.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), + ) + + OutlinedTextField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + value = account, + singleLine = true, + onValueChange = { input -> + onFormEdited(name, host, input) + }, + isError = account.isBlank(), + label = { + Text( + text = stringResource(R.string.send_deposit_account), + color = if (account.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), + ) +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/accounts/KnownBankAccounts.kt b/wallet/src/main/java/net/taler/wallet/accounts/KnownBankAccounts.kt @@ -1,126 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2022 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.accounts - -import android.net.Uri -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonClassDiscriminator - -@Serializable -data class KnownBankAccounts( - val accounts: List<KnownBankAccountsInfo>, -) - -@Serializable -data class KnownBankAccountsInfo( - val uri: PaytoUri, - @SerialName("kyc_completed") - val kycCompleted: Boolean, - val currency: String, - val alias: String, -) - -@Serializable -@OptIn(ExperimentalSerializationApi::class) -@JsonClassDiscriminator("targetType") -sealed class PaytoUri( - val isKnown: Boolean, - val targetType: String, -) { - abstract val targetPath: String - abstract val params: Map<String, String> -} - -@Serializable -@SerialName("iban") -class PaytoUriIban( - val iban: String, - val bic: String? = "SANDBOXX", - override val targetPath: String, - override val params: Map<String, String>, -) : PaytoUri( - isKnown = true, - targetType = "iban", -) { - val paytoUri: String - get() = Uri.Builder() - .scheme("payto") - .authority(targetType) - .apply { if (bic != null) appendPath(bic) } - .appendPath(iban) - .apply { - params.forEach { (key, value) -> - appendQueryParameter(key, value) - } - } - .build().toString() -} - -@Serializable -@SerialName("x-taler-bank") -class PaytoUriTalerBank( - val host: String, - val account: String, - override val targetPath: String, - override val params: Map<String, String>, -) : PaytoUri( - isKnown = true, - targetType = "x-taler-bank", -) { - val paytoUri: String - get() = Uri.Builder() - .scheme("payto") - .authority(targetType) - .appendPath(host) - .appendPath(account) - .apply { - params.forEach { (key, value) -> - appendQueryParameter(key, value) - } - } - .build().toString() -} - -@Serializable -@SerialName("bitcoin") -class PaytoUriBitcoin( - @SerialName("segwitAddrs") - val segwitAddresses: List<String>, - override val targetPath: String, - override val params: Map<String, String> = emptyMap(), -) : PaytoUri( - isKnown = true, - targetType = "bitcoin", -) { - val paytoUri: String - get() = Uri.Builder() - .scheme("payto") - .authority(targetType) - .apply { - segwitAddresses.forEach { address -> - appendPath(address) - } - } - .apply { - params.forEach { (key, value) -> - appendQueryParameter(key, value) - } - } - .build().toString() -} diff --git a/wallet/src/main/java/net/taler/wallet/compose/Avatar.kt b/wallet/src/main/java/net/taler/wallet/compose/Avatar.kt @@ -0,0 +1,49 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun Avatar( + color: Color = MaterialTheme.colorScheme.secondaryContainer, + size: Dp = 40.dp, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(color = color) + .padding(10.dp), + contentAlignment = Alignment.Center + ) { + content() + } +} diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositAmountComposable.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,11 +38,12 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R +import net.taler.wallet.accounts.BankAccountRow +import net.taler.wallet.accounts.KnownBankAccountInfo import net.taler.wallet.compose.AmountCurrencyField import net.taler.wallet.transactions.AmountType.Negative import net.taler.wallet.transactions.AmountType.Positive @@ -51,12 +52,24 @@ import net.taler.wallet.useDebounce @Composable fun DepositAmountComposable( - state: DepositState, - currency: String, - currencySpec: CurrencySpecification?, + state: DepositState.AccountSelected, + getCurrencySpec: (currency: String) -> CurrencySpecification?, checkDeposit: suspend (amount: Amount) -> CheckDepositResult, onMakeDeposit: (amount: Amount) -> Unit, + onClose: () -> Unit, ) { + val availableScopes = remember(state.maxDepositable) { + state.maxDepositable.filterValues { it?.rawAmount?.isZero() == false } + } + + if (availableScopes.isEmpty()) { + MakeDepositErrorComposable( + message = "It is not possible to deposit to this account, please select another one", + onClose = onClose, + ) + return + } + val scrollState = rememberScrollState() Column( modifier = Modifier @@ -66,7 +79,11 @@ fun DepositAmountComposable( horizontalAlignment = CenterHorizontally, ) { var checkResult by remember { mutableStateOf<CheckDepositResult>(CheckDepositResult.None()) } - var amount by remember { mutableStateOf(Amount.zero(currency)) } + // TODO: use scopeInfo instead of currency + // TODO: handle unavailable scopes in UI (i.e. explain restrictions) + val currencies = remember(availableScopes) { availableScopes.keys.toList() } + var amount by remember(state.maxDepositable) { mutableStateOf(Amount.zero(currencies.first())) } + val spec = remember(amount) { getCurrencySpec(amount.currency) } amount.useDebounce { if (!amount.isZero()) { @@ -74,18 +91,34 @@ fun DepositAmountComposable( } } - AnimatedVisibility(checkResult.maxDepositAmountEffective != null) { - checkResult.maxDepositAmountEffective?.let { + BankAccountRow( + account = state.account, + showMenu = false, + ) + + HorizontalDivider( + modifier = Modifier.padding(bottom = 16.dp), + ) + + AnimatedVisibility(checkResult.maxDepositAmountRaw != null) { + checkResult.maxDepositAmountRaw?.let { Text( modifier = Modifier.padding( start = 16.dp, end = 16.dp, bottom = 16.dp, ), - text = stringResource( - R.string.amount_available_transfer, - it.withSpec(currencySpec), - ), + text = if (checkResult.maxDepositAmountEffective == it) { + stringResource( + R.string.amount_available_transfer, + it.withSpec(spec), + ) + } else { + stringResource( + R.string.amount_available_transfer_fees, + it.withSpec(spec), + ) + }, ) } } @@ -94,10 +127,10 @@ fun DepositAmountComposable( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), - amount = amount.withSpec(currencySpec), + amount = amount.withSpec(spec), onAmountChanged = { amount = it }, - editableCurrency = false, - currencies = listOf(), + editableCurrency = true, + currencies = currencies, isError = checkResult !is CheckDepositResult.Success, label = { Text(stringResource(R.string.amount_deposit)) }, supportingText = { @@ -106,7 +139,7 @@ fun DepositAmountComposable( Text( stringResource( R.string.payment_balance_insufficient_max, - res.maxAmountEffective.withSpec(currencySpec), + res.maxAmountEffective.withSpec(spec), ) ) } @@ -140,15 +173,6 @@ fun DepositAmountComposable( } } - AnimatedVisibility(visible = state is DepositState.Error) { - Text( - modifier = Modifier.padding(16.dp), - fontSize = 18.sp, - color = MaterialTheme.colorScheme.error, - text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "", - ) - } - val focusManager = LocalFocusManager.current Button( modifier = Modifier.padding(16.dp), @@ -169,17 +193,71 @@ fun DepositAmountComposable( @Composable fun DepositAmountComposablePreview() { Surface { - val state = DepositState.AccountSelected("payto://", "KUDOS") + val state = DepositState.AccountSelected( + KnownBankAccountInfo( + bankAccountId = "acct:1234", + paytoUri = "payto://", + kycCompleted = false, + currencies = listOf("KUDOS", "TESTKUDOS"), + label = "Test accoul " + ), + maxDepositable = mapOf( + "CHF" to GetMaxDepositAmountResponse( + effectiveAmount = Amount.fromJSONString("CHF:100"), + rawAmount = Amount.fromJSONString("CHF:100"), + ), + "EUR" to GetMaxDepositAmountResponse( + effectiveAmount = Amount.fromJSONString("EUR:0"), + rawAmount = Amount.fromJSONString("EUR:0"), + ), + "MXN" to GetMaxDepositAmountResponse( + effectiveAmount = Amount.fromJSONString("MXN:1000"), + rawAmount = Amount.fromJSONString("MXN:1000"), + ), + "USD" to GetMaxDepositAmountResponse( + effectiveAmount = Amount.fromJSONString("USD:0"), + rawAmount = Amount.fromJSONString("USD:0"), + ), + ), + ) + DepositAmountComposable( + state = state, + checkDeposit = { CheckDepositResult.Success( + totalDepositCost = Amount.fromJSONString("KUDOS:10"), + effectiveDepositAmount = Amount.fromJSONString("KUDOS:12"), + maxDepositAmountEffective = Amount.fromJSONString("KUDOS:12") + ) }, + onMakeDeposit = {}, + getCurrencySpec = { null }, + onClose = {} + ) + } +} + +@Preview +@Composable +fun DepositAmountComposableErrorPreview() { + Surface { + val state = DepositState.AccountSelected( + KnownBankAccountInfo( + bankAccountId = "acct:1234", + paytoUri = "payto://", + kycCompleted = false, + currencies = listOf("KUDOS", "TESTKUDOS"), + label = "Test accoul " + ), + maxDepositable = mapOf(), + ) DepositAmountComposable( state = state, - currency = "KUDOS", - currencySpec = null, checkDeposit = { CheckDepositResult.Success( totalDepositCost = Amount.fromJSONString("KUDOS:10"), effectiveDepositAmount = Amount.fromJSONString("KUDOS:12"), maxDepositAmountEffective = Amount.fromJSONString("KUDOS:12") ) }, onMakeDeposit = {}, + getCurrencySpec = { null }, + onClose = {} ) } } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt @@ -21,13 +21,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.compose.BackHandler +import androidx.appcompat.app.AppCompatActivity 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 +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.common.showError import net.taler.wallet.MainViewModel @@ -36,12 +39,13 @@ import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.compose.collectAsStateLifecycleAware import net.taler.wallet.showError +import net.taler.wallet.accounts.ListBankAccountsResult.Success class DepositFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val depositManager get() = model.depositManager + private val accountManager get() = model.accountManager private val balanceManager get() = model.balanceManager - private val transactionManager get() = model.transactionManager override fun onCreateView( inflater: LayoutInflater, @@ -49,7 +53,6 @@ class DepositFragment : Fragment() { savedInstanceState: Bundle?, ): View { val presetAmount = arguments?.getString("amount")?.let { Amount.fromJSONString(it) } - val scopeInfo = transactionManager.selectedScope.value val receiverName = arguments?.getString("receiverName") val iban = arguments?.getString("IBAN") @@ -62,6 +65,7 @@ class DepositFragment : Fragment() { setContent { TalerSurface { val state by depositManager.depositState.collectAsStateLifecycleAware() + val knownBankAccounts by accountManager.bankAccounts.collectAsStateLifecycleAware() BackHandler(state is DepositState.AccountSelected) { depositManager.resetDepositState() @@ -79,44 +83,32 @@ class DepositFragment : Fragment() { } is DepositState.Start -> { - // TODO: refactor Bitcoin as wire method -// if (presetAmount?.currency == CURRENCY_BTC) MakeBitcoinDepositComposable( -// state = state, -// amount = presetAmount.withSpec(spec), -// bitcoinAddress = null, -// onMakeDeposit = { amount, bitcoinAddress -> -// val paytoUri = getBitcoinPayto(bitcoinAddress) -// depositManager.makeDeposit(amount, paytoUri) -// }, MakeDepositComposable( - defaultCurrency = scopeInfo?.currency, - currencies = balanceManager.getCurrencies(), - getDepositWireTypes = depositManager::getDepositWireTypesForCurrency, - presetName = receiverName, - presetIban = iban, - validateIban = depositManager::validateIban, - onPaytoSelected = { paytoUri, currency -> - depositManager.selectAccount(paytoUri, currency) - }, - onClose = { - findNavController().popBackStack() + knownBankAccounts = (knownBankAccounts as? Success) + ?.accounts + ?: emptyList(), + onAccountSelected = { account -> + depositManager.selectAccount(account) }, + onManageBankAccounts = { + findNavController().navigate(R.id.action_nav_deposit_to_known_bank_accounts) + } ) } is DepositState.AccountSelected -> { DepositAmountComposable( state = s, - currency = s.currency, - currencySpec = remember(s.currency) { - balanceManager.getSpecForCurrency(s.currency) - }, + getCurrencySpec = balanceManager::getSpecForCurrency, checkDeposit = { a -> - depositManager.checkDepositFees(s.paytoUri, a) + depositManager.checkDepositFees(s.account.paytoUri, a) }, onMakeDeposit = { amount -> - depositManager.makeDeposit(amount, s.paytoUri) + depositManager.makeDeposit(amount, s.account.paytoUri) }, + onClose = { + depositManager.resetDepositState() + } ) } } @@ -125,21 +117,44 @@ class DepositFragment : Fragment() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launchWhenStarted { - depositManager.depositState.collect { state -> - if (state is DepositState.Error) { - if (model.devMode.value == false) { - showError(state.error.userFacingMsg) - } else { - showError(state.error) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val supportActionBar = (requireActivity() as? AppCompatActivity)?.supportActionBar + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + depositManager.depositState.collect { state -> + when (state) { + is DepositState.Start -> { + supportActionBar?.setTitle(R.string.send_deposit_select_account_title) + } + + is DepositState.AccountSelected -> { + supportActionBar?.setTitle(R.string.send_deposit_select_amount_title) + } + + is DepositState.Error -> { + if (model.devMode.value == false) { + showError(state.error.userFacingMsg) + } else { + showError(state.error) + } + } + + is DepositState.Success -> { + findNavController().navigate(R.id.action_nav_deposit_to_nav_main) + } + + else -> {} } - } else if (state is DepositState.Success) { - findNavController().navigate(R.id.action_nav_deposit_to_nav_main) } } } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + accountManager.listBankAccounts() + } + } } override fun onStart() { diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt @@ -30,18 +30,21 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import net.taler.common.Amount import net.taler.wallet.TAG +import net.taler.wallet.accounts.KnownBankAccountInfo import net.taler.wallet.accounts.PaytoUriBitcoin import net.taler.wallet.accounts.PaytoUriIban import net.taler.wallet.accounts.PaytoUriTalerBank import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.balances.BalanceManager import net.taler.wallet.balances.ScopeInfo import org.json.JSONObject class DepositManager( private val api: WalletBackendApi, private val scope: CoroutineScope, + private val balanceManager: BalanceManager, ) { private val mDepositState = MutableStateFlow<DepositState>(DepositState.Start) @@ -55,14 +58,17 @@ class DepositManager( } @UiThread - fun selectAccount(paytoUri: String, currency: String) { - mDepositState.value = DepositState.AccountSelected(paytoUri, currency) + fun selectAccount(account: KnownBankAccountInfo) = scope.launch { + getMaxDepositableForPayto(account.paytoUri).let { response -> + mDepositState.value = DepositState.AccountSelected(account, response) + } } suspend fun checkDepositFees(paytoUri: String, amount: Amount): CheckDepositResult { val max = getMaxDepositAmount(amount.currency, paytoUri) var response: CheckDepositResult = CheckDepositResult.None( maxDepositAmountEffective = max?.effectiveAmount, + maxDepositAmountRaw = max?.rawAmount, ) api.request("checkDeposit", CheckDepositResponse.serializer()) { put("depositPaytoUri", paytoUri) @@ -75,6 +81,7 @@ class DepositManager( kycHardLimit = it.kycHardLimit, kycExchanges = it.kycExchanges, maxDepositAmountEffective = max?.effectiveAmount, + maxDepositAmountRaw = max?.rawAmount, ) }.onError { error -> Log.e(TAG, "Error checkDeposit $error") @@ -92,6 +99,7 @@ class DepositManager( maxAmountEffective = maxAmountEffective, maxAmountRaw = maxAmountRaw, maxDepositAmountEffective = max?.effectiveAmount, + maxDepositAmountRaw = max?.rawAmount, ) } } @@ -151,18 +159,30 @@ class DepositManager( return response } - suspend fun getDepositWireTypesForCurrency(currency: String, scopeInfo: ScopeInfo? = null): GetDepositWireTypesForCurrencyResponse? { - var result: GetDepositWireTypesForCurrencyResponse? = null - api.request("getDepositWireTypesForCurrency", GetDepositWireTypesForCurrencyResponse.serializer()) { + suspend fun getDepositWireTypes( + currency: String? = null, + scopeInfo: ScopeInfo? = null, + ): GetDepositWireTypesResponse? { + var result: GetDepositWireTypesResponse? = null + api.request("getDepositWireTypes", GetDepositWireTypesResponse.serializer()) { scopeInfo?.let { put("scopeInfo", JSONObject(BackendManager.json.encodeToString(it))) } - put("currency", currency) + currency?.let { put("currency", it) } + this }.onError { - Log.e(TAG, "Error getDepositWireTypesForCurrency $it") + Log.e(TAG, "Error getDepositWireTypes $it") }.onSuccess { result = it } return result } + + private suspend fun getMaxDepositableForPayto( + paytoUri: String, + ): Map<String, GetMaxDepositAmountResponse?> { + return balanceManager.getCurrencies().associateWith { currency -> + getMaxDepositAmount(currency, paytoUri) + } + } } fun getIbanPayto(receiverName: String, iban: String) = PaytoUriIban( @@ -170,6 +190,7 @@ fun getIbanPayto(receiverName: String, iban: String) = PaytoUriIban( bic = null, targetPath = "", params = mapOf("receiver-name" to receiverName), + receiverName = receiverName, ).paytoUri fun getTalerPayto(receiverName: String, host: String, account: String) = PaytoUriTalerBank( @@ -177,11 +198,13 @@ fun getTalerPayto(receiverName: String, host: String, account: String) = PaytoUr account = account, targetPath = "", params = mapOf("receiver-name" to receiverName), + receiverName = receiverName, ).paytoUri -fun getBitcoinPayto(bitcoinAddress: String) = PaytoUriBitcoin( +fun getBitcoinPayto(bitcoinAddress: String, receiverName: String? = null) = PaytoUriBitcoin( segwitAddresses = listOf(bitcoinAddress), targetPath = bitcoinAddress, + receiverName = receiverName, ).paytoUri @Serializable @@ -201,15 +224,18 @@ data class CheckDepositResponse( @Serializable sealed class CheckDepositResult { abstract val maxDepositAmountEffective: Amount? + abstract val maxDepositAmountRaw: Amount? data class None( - override val maxDepositAmountEffective: Amount? = null + override val maxDepositAmountEffective: Amount? = null, + override val maxDepositAmountRaw: Amount? = null, ): CheckDepositResult() data class InsufficientBalance( val maxAmountEffective: Amount?, val maxAmountRaw: Amount?, - override val maxDepositAmountEffective: Amount? + override val maxDepositAmountEffective: Amount?, + override val maxDepositAmountRaw: Amount? = null, ): CheckDepositResult() data class Success( @@ -218,7 +244,8 @@ sealed class CheckDepositResult { val kycSoftLimit: Amount? = null, val kycHardLimit: Amount? = null, val kycExchanges: List<String>? = null, - override val maxDepositAmountEffective: Amount? + override val maxDepositAmountEffective: Amount?, + override val maxDepositAmountRaw: Amount? = null, ): CheckDepositResult() } @@ -235,10 +262,29 @@ data class CreateDepositGroupResponse( ) @Serializable -data class GetDepositWireTypesForCurrencyResponse( - val wireTypes: List<WireType>, +data class GetDepositWireTypesResponse( val wireTypeDetails: List<WireTypeDetails>, -) +) { + val wireTypes: List<WireType> + get() = wireTypeDetails.map { it.paymentTargetType } + + val hostNames: List<String> + get() = wireTypeDetails + .flatMap { it.talerBankHostnames } + .distinct() +} + +@Serializable +data class GetScopesForPaytoResponse( + val scopes: List<Scope>, +) { + @Serializable + data class Scope( + val scopeInfo: ScopeInfo, + val available: Boolean, + // val restrictedAccounts: List<ExchangeWireAccount>, + ) +} @Serializable enum class WireType { diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt @@ -16,14 +16,15 @@ package net.taler.wallet.deposit +import net.taler.wallet.accounts.KnownBankAccountInfo import net.taler.wallet.backend.TalerErrorInfo sealed class DepositState { data object Start : DepositState() data class AccountSelected( - val paytoUri: String, - val currency: String, + val account: KnownBankAccountInfo, + val maxDepositable: Map<String, GetMaxDepositAmountResponse?>, ) : DepositState() data object MakingDeposit : DepositState() diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositBitcoin.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositBitcoin.kt @@ -1,69 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2024 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -package net.taler.wallet.deposit - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import net.taler.wallet.R - -@Composable -fun MakeDepositBitcoin( - bitcoinAddress: String, - onFormEdited: (bitcoinAddress: String) -> Unit, -) { - val focusRequester = remember { FocusRequester() } - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding( - bottom = 16.dp, - start = 16.dp, - end = 16.dp, - ).focusRequester(focusRequester), - value = bitcoinAddress, - singleLine = true, - onValueChange = { input -> - onFormEdited(input) - }, - isError = bitcoinAddress.isBlank(), - label = { - Text( - stringResource(R.string.send_deposit_bitcoin_address), - color = if (bitcoinAddress.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - } - ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2023 Taler Systems S.A. + * (C) 2024 Taler Systems S.A. * * GNU Taler is free software; you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software @@ -16,227 +16,66 @@ package net.taler.wallet.deposit -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button -import androidx.compose.material3.ScrollableTabRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R +import net.taler.wallet.accounts.BankAccountRow +import net.taler.wallet.accounts.KnownBankAccountInfo import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo -import net.taler.wallet.compose.WarningLabel import net.taler.wallet.peer.OutgoingError import net.taler.wallet.peer.PeerErrorComposable @Composable fun MakeDepositComposable( - defaultCurrency: String?, - currencies: List<String>, - getDepositWireTypes: suspend (currency: String) -> GetDepositWireTypesForCurrencyResponse?, - presetName: String? = null, - presetIban: String? = null, - validateIban: suspend (iban: String) -> Boolean, - onPaytoSelected: (payto: String, currency: String) -> Unit, - onClose: () -> Unit, + knownBankAccounts: List<KnownBankAccountInfo>, + onAccountSelected: (account: KnownBankAccountInfo) -> Unit, + onManageBankAccounts: () -> Unit, ) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .imePadding(), - horizontalAlignment = CenterHorizontally, + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - // TODO: use scopeInfo instead of currency - var currency by remember { mutableStateOf(defaultCurrency ?: currencies[0]) } - var depositWireTypes by remember { mutableStateOf<GetDepositWireTypesForCurrencyResponse?>(null) } - val supportedWireTypes = remember(depositWireTypes) { depositWireTypes?.wireTypes ?: emptyList() } - val talerBankHostnames = remember(depositWireTypes) { depositWireTypes?.wireTypeDetails?.flatMap { it.talerBankHostnames }?.distinct() ?: emptyList() } - var selectedWireType by remember { mutableStateOf(supportedWireTypes.firstOrNull()) } - - LaunchedEffect(currency) { - depositWireTypes = getDepositWireTypes(currency) - } - - // payto:// stuff - var formError by rememberSaveable { mutableStateOf(true) } // TODO: do an initial validation! - var ibanName by rememberSaveable { mutableStateOf(presetName ?: "") } - var ibanIban by rememberSaveable { mutableStateOf(presetIban ?: "") } - var talerName by rememberSaveable { mutableStateOf(presetName ?: "") } - var talerHost by rememberSaveable { mutableStateOf(talerBankHostnames.firstOrNull() ?: "") } - var talerAccount by rememberSaveable { mutableStateOf("") } - var bitcoinAddress by rememberSaveable { mutableStateOf("") } - - val paytoUri = when(selectedWireType) { - WireType.IBAN -> getIbanPayto(ibanName, ibanIban) - WireType.TalerBank -> getTalerPayto(talerName, talerHost, talerAccount) - WireType.Bitcoin -> getBitcoinPayto(bitcoinAddress) - else -> null - } - - // reset forms and selected wire type when switching currency - DisposableEffect(supportedWireTypes, currency) { - selectedWireType = supportedWireTypes.firstOrNull() - formError = true - ibanName = presetName ?: "" - ibanIban = presetIban ?: "" - talerName = presetName ?: "" - talerHost = talerBankHostnames.firstOrNull() ?: "" - talerAccount = "" - bitcoinAddress = "" - onDispose { } - } - - if (supportedWireTypes.isEmpty()) { - return@Column MakeDepositErrorComposable( - message = stringResource(R.string.send_deposit_no_methods_error), - onClose = onClose, + if (knownBankAccounts.isEmpty()) item { + Text( + modifier = Modifier.padding( + vertical = 32.dp, + horizontal = 16.dp, + ), + text = stringResource(R.string.send_deposit_known_bank_accounts_empty), + textAlign = TextAlign.Center, ) } - CurrencyDropdown( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - currencies = currencies, - onCurrencyChanged = { currency = it }, - initialCurrency = defaultCurrency, - ) - - if (selectedWireType != null && supportedWireTypes.size > 1) { - MakeDepositWireTypeChooser( - supportedWireTypes = supportedWireTypes, - selectedWireType = selectedWireType!!, - onSelectWireType = { - selectedWireType = it - } + items(knownBankAccounts, key = { it.bankAccountId }) { + BankAccountRow(it, + showMenu = false, + onClick = { onAccountSelected(it) }, ) } - WarningLabel( - modifier = Modifier.padding(16.dp), - label = stringResource(R.string.send_deposit_account_warning), - ) - - when(selectedWireType) { - WireType.IBAN -> { - var ibanError by rememberSaveable { mutableStateOf(false) } - val coroutineScope = rememberCoroutineScope() - - MakeDepositIBAN( - name = ibanName, - iban = ibanIban, - ibanError = ibanError, - onFormEdited = { name, iban -> - ibanName = name - ibanIban = iban - coroutineScope.launch { - val valid = validateIban(iban) - formError = !valid || name.isBlank() - ibanError = !valid - } - } - ) + item { + Button( + modifier = Modifier.padding(16.dp), + onClick = onManageBankAccounts, + ) { + Text(stringResource(R.string.send_deposit_account_manage)) } - - WireType.TalerBank -> MakeDepositTaler( - name = talerName, - host = talerHost, - account = talerAccount, - supportedHosts = talerBankHostnames, - onFormEdited = { name, host, account -> - talerName = name - talerHost = host - talerAccount = account - formError = name.isBlank() - || host.isBlank() - || account.isBlank() - } - ) - - WireType.Bitcoin -> MakeDepositBitcoin( - bitcoinAddress = bitcoinAddress, - onFormEdited = { address -> - bitcoinAddress = address - formError = address.isBlank() - } - ) - - else -> {} } - val focusManager = LocalFocusManager.current - Button( - modifier = Modifier.padding(16.dp), - enabled = !formError, - onClick = { - focusManager.clearFocus() - if (paytoUri != null) { - onPaytoSelected(paytoUri, currency) - } - }, - ) { - Text(stringResource(R.string.withdraw_select_amount)) - } - - BottomInsetsSpacer() - } -} - -@Composable -fun MakeDepositWireTypeChooser( - modifier: Modifier = Modifier, - supportedWireTypes: List<WireType>, - selectedWireType: WireType, - onSelectWireType: (wireType: WireType) -> Unit, -) { - val selectedIndex = supportedWireTypes.indexOfFirst { - it == selectedWireType - } - - ScrollableTabRow( - selectedTabIndex = selectedIndex, - modifier = modifier, - edgePadding = 8.dp, - ) { - supportedWireTypes.forEach { wireType -> - if (wireType != WireType.Unknown) { - Tab( - selected = selectedWireType == wireType, - onClick = { onSelectWireType(wireType) }, - text = { - Text(when(wireType) { - WireType.IBAN -> stringResource(R.string.send_deposit_iban) - WireType.TalerBank -> stringResource(R.string.send_deposit_taler) - WireType.Bitcoin -> stringResource(R.string.send_deposit_bitcoin) - else -> error("unknown method") - }) - } - ) - } + item { + BottomInsetsSpacer() } } } @@ -250,38 +89,8 @@ fun MakeDepositErrorComposable( state = OutgoingError(info = TalerErrorInfo( message = message, code = TalerErrorCode.UNKNOWN, - )), + ) + ), onClose = onClose, ) -} - -@Preview -@Composable -fun PreviewMakeDepositComposable() { - Surface { - MakeDepositComposable( - defaultCurrency = "KUDOS", - currencies = listOf("KUDOS", "TESTKUDOS", "NETZBON"), - getDepositWireTypes = { GetDepositWireTypesForCurrencyResponse( - wireTypes = listOf( - WireType.IBAN, - WireType.TalerBank, - WireType.Bitcoin, - ), - wireTypeDetails = listOf( - WireTypeDetails( - paymentTargetType = WireType.IBAN, - talerBankHostnames = listOf("bank.test.taler.net") - ), - WireTypeDetails( - paymentTargetType = WireType.TalerBank, - talerBankHostnames = listOf("bank.test.taler.net") - ), - ), - )}, - validateIban = { true }, - onPaytoSelected = { _, _ -> }, - onClose = {}, - ) - } -} +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositIBAN.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositIBAN.kt @@ -1,89 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2024 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -package net.taler.wallet.deposit - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import net.taler.wallet.R - -@Composable -fun MakeDepositIBAN( - name: String, - iban: String, - ibanError: Boolean, - onFormEdited: (name: String, iban: String) -> Unit -) { - OutlinedTextField( - modifier = Modifier - .padding( - bottom = 16.dp, - start = 16.dp, - end = 16.dp, - ).fillMaxWidth(), - value = name, - onValueChange = { input -> - onFormEdited(input, iban) - }, - singleLine = true, - isError = name.isBlank(), - label = { - Text( - stringResource(R.string.send_deposit_name), - color = if (name.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - } - ) - - OutlinedTextField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - value = iban, - singleLine = true, - onValueChange = { input -> - onFormEdited(name, input.uppercase()) - }, - isError = ibanError, - supportingText = { - if (ibanError) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.send_deposit_iban_error), - color = MaterialTheme.colorScheme.error - ) - } - }, - label = { - Text( - text = stringResource(R.string.send_deposit_iban), - color = if (ibanError) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - } - ) -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositTaler.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositTaler.kt @@ -1,131 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2024 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -package net.taler.wallet.deposit - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import net.taler.wallet.R - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MakeDepositTaler( - supportedHosts: List<String>, - name: String, - host: String, - account: String, - onFormEdited: (name: String, host: String, account: String) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - OutlinedTextField( - modifier = Modifier - .padding( - bottom = 16.dp, - start = 16.dp, - end = 16.dp, - ) - .fillMaxWidth() - .menuAnchor(), - readOnly = true, - value = host, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - onValueChange = {}, - label = { - Text( - stringResource(R.string.send_deposit_host), - color = if (host.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - }, - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - supportedHosts.forEach { - DropdownMenuItem( - text = { Text(it) }, - onClick = { - onFormEdited(name, it, account) - expanded = false - }, - ) - } - } - } - - OutlinedTextField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - value = name, - onValueChange = { input -> - onFormEdited(input, host, account) - }, - singleLine = true, - isError = name.isBlank(), - label = { - Text( - stringResource(R.string.send_deposit_name), - color = if (name.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - } - ) - - OutlinedTextField( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - value = account, - singleLine = true, - onValueChange = { input -> - onFormEdited(name, host, input) - }, - isError = account.isBlank(), - label = { - Text( - text = stringResource(R.string.send_deposit_account), - color = if (account.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - } - ) -} -\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -109,18 +109,25 @@ fun OutgoingPushIntroComposable( feeResult = getFees(amount) ?: None() } - AnimatedVisibility(feeResult.maxDepositAmountEffective != null) { - feeResult.maxDepositAmountEffective?.let { + AnimatedVisibility(feeResult.maxDepositAmountRaw != null) { + feeResult.maxDepositAmountRaw?.let { Text( modifier = Modifier.padding( start = 16.dp, end = 16.dp, bottom = 16.dp, ), - text = stringResource( - R.string.amount_available_transfer, - it.withSpec(selectedSpec), - ), + text = if (feeResult.maxDepositAmountEffective == it) { + stringResource( + R.string.amount_available_transfer, + it.withSpec(selectedSpec), + ) + } else { + stringResource( + R.string.amount_available_transfer_fees, + it.withSpec(selectedSpec), + ) + }, ) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -49,15 +49,18 @@ val DEFAULT_EXPIRY = ExpirationOption.DAYS_1 sealed class CheckFeeResult { abstract val maxDepositAmountEffective: Amount? + abstract val maxDepositAmountRaw: Amount? data class None( override val maxDepositAmountEffective: Amount? = null, + override val maxDepositAmountRaw: Amount? = null, ): CheckFeeResult() data class InsufficientBalance( val maxAmountEffective: Amount?, val maxAmountRaw: Amount?, override val maxDepositAmountEffective: Amount? = null, + override val maxDepositAmountRaw: Amount? = null, ): CheckFeeResult() data class Success( @@ -65,6 +68,7 @@ sealed class CheckFeeResult { val amountEffective: Amount, val exchangeBaseUrl: String, override val maxDepositAmountEffective: Amount? = null, + override val maxDepositAmountRaw: Amount? = null, ): CheckFeeResult() } @@ -145,7 +149,10 @@ class PeerManager( suspend fun checkPeerPushFees(amount: Amount, exchangeBaseUrl: String? = null): CheckFeeResult { val max = getMaxPeerPushDebitAmount(amount.currency, exchangeBaseUrl) - var response: CheckFeeResult = CheckFeeResult.None(maxDepositAmountEffective = max?.effectiveAmount) + var response: CheckFeeResult = CheckFeeResult.None( + maxDepositAmountEffective = max?.effectiveAmount, + maxDepositAmountRaw = max?.rawAmount, + ) api.request("checkPeerPushDebit", CheckPeerPushDebitResponse.serializer()) { exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } put("amount", amount.toJSONString()) @@ -154,6 +161,7 @@ class PeerManager( amountRaw = it.amountRaw, amountEffective = it.amountEffective, maxDepositAmountEffective = max?.effectiveAmount, + maxDepositAmountRaw = max?.rawAmount, exchangeBaseUrl = it.exchangeBaseUrl, ) }.onError { error -> @@ -172,6 +180,7 @@ class PeerManager( maxAmountEffective = maxAmountEffective, maxAmountRaw = maxAmountRaw, maxDepositAmountEffective = max?.effectiveAmount, + maxDepositAmountRaw = max?.rawAmount, ) } } diff --git a/wallet/src/main/res/navigation/nav_graph.xml b/wallet/src/main/res/navigation/nav_graph.xml @@ -132,6 +132,9 @@ android:id="@+id/action_nav_deposit_to_nav_main" app:destination="@id/nav_main" app:popUpTo="@id/nav_main" /> + <action + android:id="@+id/action_nav_deposit_to_known_bank_accounts" + app:destination="@id/bankAccountsFragment" /> </fragment> <fragment @@ -314,6 +317,25 @@ android:label="@string/nav_error" tools:layout="@layout/fragment_error" /> + <fragment + android:id="@+id/bankAccountsFragment" + android:name="net.taler.wallet.accounts.BankAccountsFragment" + android:label="@string/send_deposit_known_bank_accounts"> + <action + android:id="@+id/action_nav_bank_accounts_to_add_bank_account" + app:destination="@+id/addBankAccountFragment" /> + </fragment> + + <fragment + android:id="@+id/addBankAccountFragment" + android:name="net.taler.wallet.accounts.AddAccountFragment" + android:label="@string/send_deposit_account_add"> + <argument + android:name="bankAccountId" + app:nullable="true" + app:argType="string" /> + </fragment> + <action android:id="@+id/action_global_handle_uri" app:destination="@id/handleUri" /> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -86,6 +86,7 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <!-- Amounts --> <string name="amount_available_transfer">Available for transfer: %1$s</string> + <string name="amount_available_transfer_fees">Available for transfer after fees: %1$s</string> <string name="amount_chosen">Chosen amount</string> <string name="amount_conversion">Conversion</string> <string name="amount_deposit">Amount to deposit</string> @@ -215,6 +216,13 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="pay_peer_intro">Do you want to pay this request?</string> <string name="pay_peer_title">Pay request</string> <string name="send_deposit_account">Account</string> + <string name="send_deposit_account_add">Add account</string> + <string name="send_deposit_account_edit">Edit account</string> + <string name="send_deposit_account_error">It is not possible to deposit to this account, please select another one</string> + <string name="send_deposit_account_forget_dialog_message">Do you really wish to forget this account in the wallet?</string> + <string name="send_deposit_account_forget_dialog_title">Forget account</string> + <string name="send_deposit_account_note">Note</string> + <string name="send_deposit_account_manage">Manage bank accounts</string> <string name="send_deposit_account_warning">You must enter an account that you control, otherwise you will not be able to fulfill the regulatory requirements.</string> <string name="send_deposit_bitcoin">Bitcoin</string> <string name="send_deposit_bitcoin_address">Bitcoin address</string> @@ -225,10 +233,16 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="send_deposit_host">Local currency bank</string> <string name="send_deposit_iban">IBAN</string> <string name="send_deposit_iban_error">IBAN is invalid</string> + <string name="send_deposit_known_bank_accounts">Known bank accounts</string> + <string name="send_deposit_known_bank_account_delete">Delete</string> + <string name="send_deposit_known_bank_accounts_empty">No bank accounts saved in the wallet</string> <string name="send_deposit_name">Account holder</string> <string name="send_deposit_no_methods_error">No supported wire methods</string> + <string name="send_deposit_select_account_title">Select bank account</string> + <string name="send_deposit_select_amount_title">Select amount to deposit</string> <string name="send_deposit_taler">x-taler-bank</string> <string name="send_deposit_title">Deposit to bank account</string> + <string name="send_deposit_no_alias">No alias</string> <string name="send_peer_create_button">Send funds now</string> <string name="send_peer_expiration_1d">1 day</string> <string name="send_peer_expiration_30d">30 days</string>