Searching transactions for other party to suggest recipients in TransferMoneyDialog

This commit is contained in:
dankito 2024-09-04 20:58:21 +02:00
parent 87ed7018d0
commit 95be00ad66
5 changed files with 271 additions and 8 deletions

View File

@ -9,6 +9,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -19,9 +20,11 @@ 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.AutocompleteTextFieldNew
import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.error.ErroneousAction
import net.codinux.banking.ui.service.RecipientFinder
private val uiState = DI.uiState
@ -44,6 +47,7 @@ fun TransferMoneyDialog(
return
}
var senderAccount by remember { mutableStateOf(accountsSupportingTransferringMoney.first()) }
var recipientName by remember { mutableStateOf("") }
@ -58,6 +62,10 @@ fun TransferMoneyDialog(
derivedStateOf { recipientName.length > 2 && recipientAccountIdentifier.length >= 18 && amount.isNotBlank() }
}
val recipientFinder = RecipientFinder(DI.bankFinder)
var isTransferringMoney by remember { mutableStateOf(false) }
val verticalSpace = 8.dp
@ -65,6 +73,11 @@ fun TransferMoneyDialog(
val coroutineScope = rememberCoroutineScope()
coroutineScope.launch {
recipientFinder.updateData(bankingService.getAllAccountTransactions()) // only a bit problematic: if in the meantime new transactions are retrieved, then RecipientFinder doesn't contain the newly retrieved transactions
}
fun confirmCalled() {
isTransferringMoney = true
@ -72,7 +85,8 @@ fun TransferMoneyDialog(
val successful = bankingService.transferMoney(
accountsToUserAccount[senderAccount]!!, senderAccount,
recipientName, recipientAccountIdentifier,
Amount(amount), "EUR", // TODO: add input field for currency
Amount(amount), // TODO: verify entered amount is valid
"EUR", // TODO: add input field for currency
paymentReference, instantTransfer && accountSupportsInstantTransfer
// TODO: determine BIC to IBAN
)
@ -111,13 +125,37 @@ fun TransferMoneyDialog(
}
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)
)
AutocompleteTextFieldNew(
"Name des Empfängers / der Empfängerin",
dropdownMaxHeight = 350.dp,
minTextLengthForSearch = 0,
onEnteredTextChanged = { recipientName = it },
onSelectedItemChanged = {
if (it != null) {
recipientName = it.name
recipientAccountIdentifier = it.accountIdentifier
// TODO: set recipient bank identifier
} else {
recipientName = ""
recipientAccountIdentifier = ""
}
},
fetchSuggestions = { query -> recipientFinder.findRecipients(query) }
) { recipientSuggestion ->
Column(Modifier.fillMaxWidth()) {
Text(recipientSuggestion.name)
Row(Modifier.padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Text(recipientSuggestion.bankIdentifier ?: "", Modifier.width(105.dp))
recipientSuggestion.bankInfo?.let { bank ->
Text("${bank.name}, ${bank.city}", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Text(recipientSuggestion.accountIdentifier, Modifier.padding(top = 6.dp))
}
}
Spacer(modifier = Modifier.height(verticalSpace))

View File

@ -0,0 +1,136 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.Colors
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T> AutocompleteTextFieldNew(
label: String,
onSelectedItemChanged: (T?) -> Unit,
onEnteredTextChanged: ((String) -> Unit)? = null,
minTextLengthForSearch: Int = 1,
showDividersBetweenItems: Boolean = true,
getItemTitle: ((T) -> String)? = null,
dropdownMaxHeight: Dp = 400.dp,
leadingIcon: @Composable (() -> Unit)? = null,
fetchSuggestions: (query: String) -> Collection<T> = { emptyList() },
suggestionContent: @Composable (T) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
var suggestions by remember { mutableStateOf<Collection<T>>(emptyList()) }
val textFieldFocus = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val interactionSource = remember { MutableInteractionSource() }
val coroutineScope = rememberCoroutineScope()
ExposedDropdownMenuBox(expanded, { isExpanded -> expanded = isExpanded }, Modifier.fillMaxWidth()) {
OutlinedTextField(
value = searchQuery,
onValueChange = { query ->
searchQuery = query
onSelectedItemChanged(null)
onEnteredTextChanged?.invoke(query)
if (query.length >= minTextLengthForSearch) {
isLoading = true
coroutineScope.launch {
suggestions = fetchSuggestions(query)
isLoading = false
if (expanded == false) {
expanded = true
}
// Delay to ensure menu is fully displayed before requesting focus
delay(100)
textFieldFocus.requestFocus() // Request focus back to TextField
keyboardController?.show() // Show keyboard
delay(1000)
textFieldFocus.requestFocus()
keyboardController?.show() // Show keyboard
}
} else {
suggestions = emptyList()
expanded = false
}
},
modifier = Modifier.fillMaxWidth().focusRequester(textFieldFocus),
interactionSource = interactionSource,
label = { Text(label) },
maxLines = 1,
trailingIcon = {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Sucheingabe löschen",
modifier = Modifier.clickable {
searchQuery = ""
suggestions = emptyList()
expanded = false
onSelectedItemChanged(null)
textFieldFocus.requestFocus()
}.focusProperties { canFocus = false }
)
}
},
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(
expanded = expanded && suggestions.isNotEmpty(),
onDismissRequest = { expanded = false },
Modifier.exposedDropdownSize(true).heightIn(max = dropdownMaxHeight),
properties = PopupProperties(focusable = false)
) {
suggestions.forEachIndexed { index, item ->
Column(
Modifier.fillMaxWidth().padding(8.dp).clickable {
onSelectedItemChanged(item)
getItemTitle?.let {
searchQuery = it.invoke(item)
}
expanded = false
}
) {
suggestionContent(item)
}
if (showDividersBetweenItems && index < suggestions.size - 1) {
Divider(color = Colors.Zinc200, thickness = 1.dp)
}
}
}
}
}

View File

@ -0,0 +1,8 @@
package net.codinux.banking.ui.model
data class RecipientSuggestion(
val name: String,
val bankIdentifier: String?,
val accountIdentifier: String,
var bankInfo: BankInfo? = null
)

View File

@ -39,6 +39,44 @@ class BankFinder {
.max(maxItems)
}
suspend fun findBankByBicOrIban(bic: String?, iban: String): BankInfo? {
if (bic == null || iban.length < 9) {
return null
}
return findBankByBic(bic) ?: findBankByIban(iban)
}
suspend fun findBankByBic(bic: String): BankInfo? {
if (bic.length != 8 && bic.length != 11) {
return null
}
val result = getBankList().asSequence().filter { it.bic == bic || (bic.length == 8 && it.bic.startsWith(bic)) }.max(2)
return if (result.size > 1) { // non unique result, but should actually never happen for BICs
null
} else {
result.firstOrNull()
}
}
suspend fun findBankByIban(iban: String): BankInfo? {
if (iban.length < 9) {
return null
}
val bankCode = iban.substring(4) // first two letters are the country code, third and fourth char are the checksum, bank code starts at 5th char
val result = getBankList().asSequence().filter { it.bankCode.startsWith(bankCode) }.max(2)
return if (result.size > 1) { // non unique result, but should actually never happen for BICs
null
} else {
result.firstOrNull()
}
}
suspend fun findBankByNameBankCodeOrCityForNonEmptyQuery(query: String, maxItems: Int?): List<BankInfo> {
val queryPartsLowerCase = query.lowercase().split(" ", "-")

View File

@ -0,0 +1,43 @@
package net.codinux.banking.ui.service
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.ui.model.RecipientSuggestion
class RecipientFinder(private val bankFinder: BankFinder) {
private var availableRecipients: Set<RecipientSuggestion> = emptySet()
fun findRecipients(query: String): Collection<RecipientSuggestion> {
if (query.isBlank()) {
return availableRecipients
}
val singleTerms = query.lowercase().split(' ').filter { it.isNotBlank() }
val numericTerms = singleTerms.filter { it.toIntOrNull() != null }.toHashSet()
return availableRecipients.filter { recipient ->
val nameParts = recipient.name.lowercase().split(' ', '-').filter { it.isNotBlank() }
singleTerms.all { term ->
nameParts.any { it.startsWith(term) } ||
(recipient.bankIdentifier != null && recipient.bankIdentifier.contains(term, true)) ||
(numericTerms.contains(term) && recipient.accountIdentifier.contains(term))
}
}
}
suspend fun updateData(transactions: List<AccountTransaction>) {
availableRecipients = transactions.mapNotNull {
if (it.otherPartyName != null && it.otherPartyAccountId != null) {
RecipientSuggestion(it.otherPartyName!!, it.otherPartyBankCode, it.otherPartyAccountId!!)
} else {
null
}
}
.toSet()
.onEach { // do this after toSet() so that we don't have to call findBankByBicOrIban() for all the duplicates
it.bankInfo = bankFinder.findBankByBicOrIban(it.bankIdentifier, it.accountIdentifier)
}
}
}