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