From 95be00ad66986a28017c6d96e980fd6058cc188d Mon Sep 17 00:00:00 2001 From: dankito Date: Wed, 4 Sep 2024 20:58:21 +0200 Subject: [PATCH] Searching transactions for other party to suggest recipients in TransferMoneyDialog --- .../banking/ui/dialogs/TransferMoneyDialog.kt | 54 +++++-- .../ui/forms/AutocompleteTextFieldNew.kt | 136 ++++++++++++++++++ .../banking/ui/model/RecipientSuggestion.kt | 8 ++ .../codinux/banking/ui/service/BankFinder.kt | 38 +++++ .../banking/ui/service/RecipientFinder.kt | 43 ++++++ 5 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/net/codinux/banking/ui/forms/AutocompleteTextFieldNew.kt create mode 100644 composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/RecipientSuggestion.kt create mode 100644 composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/RecipientFinder.kt diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/TransferMoneyDialog.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/TransferMoneyDialog.kt index f9ae3b0..62124cf 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/TransferMoneyDialog.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/TransferMoneyDialog.kt @@ -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)) diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/forms/AutocompleteTextFieldNew.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/forms/AutocompleteTextFieldNew.kt new file mode 100644 index 0000000..fdb27bf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/forms/AutocompleteTextFieldNew.kt @@ -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 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 = { 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>(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) + } + } + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/RecipientSuggestion.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/RecipientSuggestion.kt new file mode 100644 index 0000000..4724d0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/RecipientSuggestion.kt @@ -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 +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankFinder.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankFinder.kt index e76a37b..8ab147a 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankFinder.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankFinder.kt @@ -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 { val queryPartsLowerCase = query.lowercase().split(" ", "-") diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/RecipientFinder.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/RecipientFinder.kt new file mode 100644 index 0000000..4985d3a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/RecipientFinder.kt @@ -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 = emptySet() + + + fun findRecipients(query: String): Collection { + 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) { + 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) + } + } + +} \ No newline at end of file