Implemented TransferMoneyDialog

This commit is contained in:
dankito 2024-09-04 17:12:36 +02:00
parent 1368ece023
commit dde54b75d3
12 changed files with 311 additions and 21 deletions

View File

@ -5,16 +5,16 @@ import androidx.compose.material.SnackbarHostState
import androidx.compose.runtime.*
import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.dialogs.AddAccountDialog
import net.codinux.banking.ui.dialogs.ApplicationErrorDialog
import net.codinux.banking.ui.dialogs.BankingClientErrorDialog
import net.codinux.banking.ui.dialogs.EnterTanDialog
import net.codinux.banking.ui.dialogs.*
import net.codinux.banking.ui.screens.ExportScreen
import net.codinux.banking.ui.state.UiState
private val formatUtil = DI.formatUtil
@Composable
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
val showTransferMoneyDialog by uiState.showTransferMoneyDialog.collectAsState()
val showExportScreen by uiState.showExportScreen.collectAsState()
val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState()
@ -28,6 +28,10 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
AddAccountDialog { uiState.showAddAccountDialog.value = false }
}
if (showTransferMoneyDialog) {
TransferMoneyDialog { uiState.showTransferMoneyDialog.value = false }
}
if (showExportScreen) {
ExportScreen { uiState.showExportScreen.value = false }
}
@ -54,7 +58,7 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
LaunchedEffect(Unit) {
coroutineScope.launch {
DI.uiState.transactionsRetrievedEvents.collect { event ->
uiState.transactionsRetrievedEvents.collect { event ->
val messagePrefix = if (event.newTransactions.isEmpty()) {
"Keine neuen Umsätze"
} else if (event.newTransactions.size == 1) {
@ -70,6 +74,16 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
)
}
}
coroutineScope.launch {
uiState.transferredMoneyEvents.collect { event ->
snackbarHostState.showSnackbar(
message = "${formatUtil.formatAmount(event.amount, event.currency)} wurden erfolgreich an ${event.recipientName} überwiesen",
actionLabel = "Das freut mich",
duration = SnackbarDuration.Long
)
}
}
}
}

View File

@ -8,6 +8,8 @@ object Internationalization {
const val ErrorUpdateAccountTransactions = "Umsätze konnten nicht aktualisiert werden"
const val ErrorTransferMoney = "Überweisung konnte nicht ausgeführt werden"
fun getTextForActionRequiringTan(action: ActionRequiringTan): String = when (action) {
ActionRequiringTan.GetAnonymousBankInfo,

View File

@ -10,6 +10,7 @@ fun ApplicationErrorDialog(error: ApplicationError, onDismiss: (() -> Unit)? = n
val title = when (error.erroneousAction) {
ErroneousAction.AddAccount -> Internationalization.ErrorAddAccount
ErroneousAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions
ErroneousAction.TransferMoney -> Internationalization.ErrorTransferMoney
}
// add exception stacktrace?

View File

@ -11,6 +11,7 @@ fun BankingClientErrorDialog(error: BankingClientError, onDismiss: (() -> Unit)?
val title = when (error.erroneousAction) {
BankingClientAction.AddAccount -> Internationalization.ErrorAddAccount
BankingClientAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions
BankingClientAction.TransferMoney -> Internationalization.ErrorTransferMoney
}
val text = if (error.error.internalError != null) {

View File

@ -0,0 +1,165 @@
package net.codinux.banking.ui.dialogs
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.BankAccountFeatures
import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.error.ErroneousAction
private val uiState = DI.uiState
private val bankingService = DI.bankingService
@Composable
fun TransferMoneyDialog(
onDismiss: () -> Unit,
) {
val userAccounts = uiState.userAccounts.value
val accountsToUserAccount = userAccounts.sortedBy { it.displayIndex }
.flatMap { user -> user.accounts.sortedBy { it.displayIndex }.map { it to user } }.toMap()
val accountsSupportingTransferringMoney = userAccounts.flatMap { it.accounts }
.filter { it.supportsAnyFeature(BankAccountFeatures.TransferMoney, BankAccountFeatures.InstantPayment) }
if (accountsSupportingTransferringMoney.isEmpty()) {
uiState.applicationErrorOccurred(ErroneousAction.TransferMoney, "Keines Ihrer Konten unterstützt das Überweisen von Geld")
onDismiss()
return
}
var senderAccount by remember { mutableStateOf(accountsSupportingTransferringMoney.first()) }
var recipientName by remember { mutableStateOf("") }
var recipientAccountIdentifier by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var paymentReference by remember { mutableStateOf("") }
val accountSupportsInstantTransfer by remember(senderAccount) { derivedStateOf { senderAccount.supportsAnyFeature(BankAccountFeatures.InstantPayment) } }
var instantTransfer by remember { mutableStateOf(false) }
val isRequiredDataEntered by remember(recipientName, recipientAccountIdentifier, amount) {
// TODO: add check if it's a valid IBAN
derivedStateOf { recipientName.length > 2 && recipientAccountIdentifier.length >= 18 && amount.isNotBlank() }
}
var isTransferringMoney by remember { mutableStateOf(false) }
val verticalSpace = 8.dp
val coroutineScope = rememberCoroutineScope()
fun confirmCalled() {
isTransferringMoney = true
coroutineScope.launch(Dispatchers.IOorDefault) {
val successful = bankingService.transferMoney(
accountsToUserAccount[senderAccount]!!, senderAccount,
recipientName, recipientAccountIdentifier,
Amount(amount), "EUR", // TODO: add input field for currency
paymentReference, instantTransfer && accountSupportsInstantTransfer
// TODO: determine BIC to IBAN
)
withContext(Dispatchers.Main) {
isTransferringMoney = false
if (successful) {
onDismiss()
}
}
}
}
BaseDialog(
title = "Neue Überweisung ...",
confirmButtonTitle = "Überweisen",
confirmButtonEnabled = isRequiredDataEntered && isTransferringMoney == false,
showProgressIndicatorOnConfirmButton = isTransferringMoney,
useMoreThanPlatformDefaultWidthOnMobile = true,
onDismiss = onDismiss,
onConfirm = { confirmCalled() }
) {
Select(
"Konto", "Alle Konten anzeigen",
accountsSupportingTransferringMoney, senderAccount, { senderAccount = it },
{ account -> "${accountsToUserAccount[account]?.displayName} ${account.displayName}" },
{ BankIcon(accountsToUserAccount[senderAccount]) }
) { account ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
BankIcon(accountsToUserAccount[account], Modifier.padding(end = 6.dp))
Text("${accountsToUserAccount[account]?.displayName} ${account.displayName}")
}
}
Column(Modifier.padding(top = verticalSpace)) {
OutlinedTextField(
value = recipientName,
onValueChange = { recipientName = it },
label = { Text("Name des Empfängers / der Empfängerin") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(modifier = Modifier.height(verticalSpace))
OutlinedTextField(
value = recipientAccountIdentifier,
onValueChange = { recipientAccountIdentifier = it },
label = { Text("IBAN") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(modifier = Modifier.height(verticalSpace))
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text("Betrag") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next)
)
Spacer(modifier = Modifier.height(verticalSpace))
OutlinedTextField(
value = paymentReference,
onValueChange = { paymentReference = it },
label = { Text("Verwendungszweck") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Row(Modifier.padding(top = verticalSpace), verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = instantTransfer,
onCheckedChange = { instantTransfer = it },
enabled = accountSupportsInstantTransfer,
modifier = Modifier.padding(end = 4.dp),
colors = SwitchDefaults.colors(checkedThumbColor = Colors.CodinuxSecondaryColor)
)
Text("Echtzeitüberweisung (evtl. kostenpflichtig)", Modifier.clickable { instantTransfer = !instantTransfer })
}
}
}
}

View File

@ -0,0 +1,59 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T> Select(
label: String,
contentDescription: String,
items: Collection<T>,
selectedItem: T,
onSelectedItemChanged: (T) -> Unit,
getItemDisplayText: (T) -> String,
leadingIcon: @Composable (() -> Unit)? = null,
dropDownItemContent: @Composable ((T) -> Unit)? = null
) {
var showDropDownMenu by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(showDropDownMenu, { isExpanded -> showDropDownMenu = isExpanded }, Modifier.fillMaxWidth()) {
OutlinedTextField(
value = getItemDisplayText(selectedItem),
onValueChange = { },
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
readOnly = true,
maxLines = 1,
trailingIcon = {
if (showDropDownMenu) {
Icon(Icons.Filled.ArrowDropUp, contentDescription)
} else {
Icon(Icons.Filled.ArrowDropDown, contentDescription)
}
},
leadingIcon = leadingIcon
)
// due to a bug (still not fixed since 2021) in ExposedDropdownMenu its popup has a maximum width of 800 pixel / 320dp which is too less to fit
// TextField's width, see https://issuetracker.google.com/issues/205589613
DropdownMenu(showDropDownMenu, { showDropDownMenu = false }, Modifier.exposedDropdownSize(true)) {
items.forEach { item ->
DropdownMenuItem(
onClick = {
showDropDownMenu = false
onSelectedItemChanged(item)
}
) {
dropDownItemContent?.invoke(item) ?: Text(getItemDisplayText(item))
}
}
}
}
}

View File

@ -2,5 +2,8 @@ package net.codinux.banking.ui.model.error
enum class BankingClientAction {
AddAccount,
UpdateAccountTransactions
UpdateAccountTransactions,
TransferMoney
}

View File

@ -2,5 +2,8 @@ package net.codinux.banking.ui.model.error
enum class ErroneousAction {
AddAccount,
UpdateAccountTransactions
UpdateAccountTransactions,
TransferMoney
}

View File

@ -4,7 +4,7 @@ import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.ui.model.AccountTransactionViewModel
data class AccountTransactionsRetrieved(
data class AccountTransactionsRetrievedEvent(
val user: UserAccount,
val account: BankAccount,
val newTransactions: List<AccountTransactionViewModel>

View File

@ -0,0 +1,9 @@
package net.codinux.banking.ui.model.events
import net.codinux.banking.client.model.Amount
data class TransferredMoneyEvent(
val recipientName: String,
val amount: Amount,
val currency: String
)

View File

@ -9,6 +9,7 @@ import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.options.RetrieveTransactions
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.request.TransferMoneyRequestForUser
import net.codinux.banking.client.model.response.*
import net.codinux.banking.client.service.BankingModelService
import net.codinux.banking.dataaccess.BankingRepository
@ -22,7 +23,8 @@ import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.BankInfo
import net.codinux.banking.ui.model.TanChallengeReceived
import net.codinux.banking.ui.model.error.*
import net.codinux.banking.ui.model.events.AccountTransactionsRetrieved
import net.codinux.banking.ui.model.events.AccountTransactionsRetrievedEvent
import net.codinux.banking.ui.model.events.TransferredMoneyEvent
import net.codinux.banking.ui.state.UiState
import net.codinux.csv.reader.CsvReader
import net.codinux.log.logger
@ -81,7 +83,7 @@ class BankingService(
} catch (e: Throwable) {
log.error(e) { "Could not add account for ${bank.name} $loginName" }
uiState.applicationErrorOccurred(ErroneousAction.AddAccount, e)
uiState.applicationErrorOccurred(ErroneousAction.AddAccount, null, e)
return false
}
@ -157,7 +159,7 @@ class BankingService(
}
val transactionsViewModel = updateTransactionsInUi(newTransactionsEntities)
uiState.fireNewTransactionsRetrieved(AccountTransactionsRetrieved(user, account, transactionsViewModel))
uiState.dispatchNewTransactionsRetrieved(AccountTransactionsRetrievedEvent(user, account, transactionsViewModel))
}
} catch (e: Throwable) {
log.error(e) { "Could not save updated account transactions for user $user" }
@ -175,6 +177,29 @@ class BankingService(
}
suspend fun transferMoney(user: UserAccountEntity, account: BankAccountEntity,
recipientName: String, recipientAccountIdentifier: String, amount: Amount, currency: String,
paymentReference: String? = null, instantTransfer: Boolean = false, recipientBankIdentifier: String? = null): Boolean {
val response = client.transferMoneyAsync(TransferMoneyRequestForUser(
user.bankCode, user.loginName, user.password!!,
BankAccountIdentifier(account.identifier, account.subAccountNumber, account.iban), // TODO: use BankingClient's one
recipientName, recipientAccountIdentifier, recipientBankIdentifier,
amount, "EUR",
paymentReference, instantTransfer
))
if (response.error != null) {
handleUnsuccessfulBankingClientResponse(BankingClientAction.TransferMoney, response)
} else if (response.type == ResponseType.Success) {
uiState.dispatchTransferredMoney(TransferredMoneyEvent(recipientName, amount, currency))
updateAccountTransactions(user, account)
}
return response.type == ResponseType.Success
}
private fun handleUnsuccessfulBankingClientResponse(action: BankingClientAction, response: Response<*>) {
log.error { "$action was not successful: $response" }

View File

@ -14,7 +14,8 @@ import net.codinux.banking.ui.model.TanChallengeReceived
import net.codinux.banking.ui.model.error.ApplicationError
import net.codinux.banking.ui.model.error.BankingClientError
import net.codinux.banking.ui.model.error.ErroneousAction
import net.codinux.banking.ui.model.events.AccountTransactionsRetrieved
import net.codinux.banking.ui.model.events.AccountTransactionsRetrievedEvent
import net.codinux.banking.ui.model.events.TransferredMoneyEvent
class UiState : ViewModel() {
@ -22,14 +23,17 @@ class UiState : ViewModel() {
val transactions = MutableStateFlow<List<AccountTransactionViewModel>>(emptyList())
val transactionsRetrievedEvents = MutableSharedFlow<AccountTransactionsRetrieved>()
val transactionsRetrievedEvents = MutableSharedFlow<AccountTransactionsRetrievedEvent>()
suspend fun fireNewTransactionsRetrieved(event: AccountTransactionsRetrieved) {
coroutineScope {
launch {
transactionsRetrievedEvents.emit(event)
}
}
suspend fun dispatchNewTransactionsRetrieved(event: AccountTransactionsRetrievedEvent) {
transactionsRetrievedEvents.emit(event)
}
val transferredMoneyEvents = MutableSharedFlow<TransferredMoneyEvent>()
suspend fun dispatchTransferredMoney(event: TransferredMoneyEvent) {
transferredMoneyEvents.emit(event)
}
@ -41,8 +45,11 @@ class UiState : ViewModel() {
val showAddAccountDialog = MutableStateFlow(false)
val showTransferMoneyDialog = MutableStateFlow(false)
val showExportScreen = MutableStateFlow(false)
val tanChallengeReceived = MutableStateFlow<TanChallengeReceived?>(null)
val bankingClientErrorOccurred = MutableStateFlow<BankingClientError?>(null)
@ -50,9 +57,10 @@ class UiState : ViewModel() {
val applicationErrorOccurred = MutableStateFlow<ApplicationError?>(null)
fun applicationErrorOccurred(erroneousAction: ErroneousAction, exception: Throwable, errorMessage: String? = null) {
fun applicationErrorOccurred(erroneousAction: ErroneousAction, errorMessage: String? = null, exception: Throwable? = null) {
val message = errorMessage
?: exception.message ?: exception::class.simpleName // TODO: find a better way to get error message from exception
?: exception?.message // TODO: find a better way to get error message from exception
?: exception?.let { it::class.simpleName }
if (message != null) {
applicationErrorOccurred.value = ApplicationError(erroneousAction, message, exception)