AddAccountComposable.kt
/*
* 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.accounts
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.backend.TalerErrorInfo
import net.taler.wallet.compose.ErrorComposable
import net.taler.wallet.compose.WarningLabel
import net.taler.wallet.deposit.AddAccountBitcoin
import net.taler.wallet.deposit.AddAccountIBAN
import net.taler.wallet.deposit.AddAccountTaler
import net.taler.wallet.deposit.GetDepositWireTypesResponse
import net.taler.wallet.deposit.WireType
import net.taler.wallet.deposit.WireTypeDetails
import net.taler.wallet.deposit.getBitcoinPayto
import net.taler.wallet.deposit.getIbanPayto
import net.taler.wallet.deposit.getTalerPayto
import net.taler.wallet.useDebounce
@Composable
fun AddAccountComposable(
modifier: Modifier = Modifier,
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.filterNot { it == WireType.Unknown }
}
val talerBankHostnames = remember(depositWireTypes) { depositWireTypes.hostNames }
val presetPaytoUri = remember(presetAccount) { presetAccount?.let { PaytoUri.parse(it.paytoUri) } }
if (supportedWireTypes.isEmpty()) {
return ErrorComposable(
error = TalerErrorInfo.makeCustomError(
stringResource(R.string.send_deposit_no_methods_error)),
modifier = Modifier.fillMaxSize(),
devMode = false,
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 ibanTown by rememberSaveable(presetPaytoUri) { mutableStateOf((presetPaytoUri as? PaytoUriIban)?.receiverTown) }
var ibanZip by rememberSaveable(presetPaytoUri) { mutableStateOf((presetPaytoUri as? PaytoUriIban)?.receiverPostalCode) }
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.trim(),
ibanZip?.trim(),
ibanTown?.trim(),
ibanIban.trim()
)
WireType.TalerBank -> getTalerPayto(talerName.trim(), talerHost.trim(), talerAccount.trim())
WireType.Bitcoin -> getBitcoinPayto(bitcoinAddress.trim())
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,
town = ibanTown,
zip = ibanZip,
iban = ibanIban,
ibanError = ibanError,
onFormEdited = { name, town, zip, iban ->
ibanName = name
ibanTown = town
ibanZip = zip
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")
})
}
)
}
}
}
}
@Preview
@Composable
fun PreviewAddAccountComposable() {
Surface {
AddAccountComposable(
modifier = Modifier,
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 = {},
)
}
}