Implemented searching for banks in AddAccountDialog

This commit is contained in:
dankito 2024-08-25 16:05:15 +02:00
parent 5f25b51487
commit 972af95a11
11 changed files with 272 additions and 16 deletions

View File

@ -59,6 +59,7 @@ kotlin {
implementation(libs.kcsv)
implementation(libs.klf)
implementation(libs.kotlinx.serializable)
// UI
implementation(compose.runtime)

File diff suppressed because one or more lines are too long

View File

@ -17,15 +17,15 @@ import kotlinx.coroutines.launch
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.ui.composables.TransactionsList
import net.codinux.banking.ui.dialogs.AddAccountDialog
import net.codinux.banking.ui.service.BankingService
import net.codinux.banking.ui.service.Colors
import net.codinux.banking.ui.service.DI
import org.jetbrains.compose.ui.tooling.preview.Preview
private val typography = Typography(
body1 = TextStyle(fontSize = 14.sp, color = Colors.Zinc700)
)
private val bankService = BankingService()
private val bankService = DI.bankingService
@Composable
@Preview

View File

@ -1,6 +1,7 @@
package net.codinux.banking.ui.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@ -12,10 +13,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.ui.forms.PasswordTextField
import net.codinux.banking.ui.forms.RoundedCornersCard
import net.codinux.banking.ui.forms.*
import net.codinux.banking.ui.model.BankInfo
import net.codinux.banking.ui.service.Colors
import net.codinux.banking.ui.service.DI
private val bankingService = DI.bankingService
@Composable
fun AddAccountDialog(
@ -25,12 +29,10 @@ fun AddAccountDialog(
var loginName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val isRequiredDataEntered by remember(bankCode, loginName, password) {
derivedStateOf { bankCode.length == 8 && loginName.length > 3 && password.length > 3 }
val isRequiredDataEntered by remember(selectedBank, loginName, password) {
derivedStateOf { selectedBank != null && loginName.length > 3 && password.length > 3 }
}
val coroutineScope = rememberCoroutineScope()
Dialog(onDismissRequest = onDismiss) {
RoundedCornersCard {
Column(Modifier.background(Color.White).padding(8.dp)) {
@ -49,13 +51,26 @@ fun AddAccountDialog(
}
}
OutlinedTextField(
value = bankCode,
onValueChange = { bankCode = it },
AutocompleteTextField(
onValueChange = { selectedBank = it },
label = { Text("Bank (Suche mit Name, Bankleitzahl oder Ort)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
fetchSuggestions = { query -> bankingService.findBanks(query) }
) { bank ->
Column(
Modifier.fillMaxWidth().clickable {
selectedItem = bank
}
.padding(8.dp)
) {
Text(bank.name)
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text(bank.bankCode)
Text("${bank.postalCode} ${bank.city}", Modifier.weight(1f).padding(start = 8.dp), color = Color.Gray)
}
}
}
Spacer(modifier = Modifier.height(24.dp))

View File

@ -0,0 +1,94 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.DropdownMenu
import androidx.compose.material.Icon
import androidx.compose.material.Text
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.geometry.Size
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import kotlinx.coroutines.launch
@Composable
fun <T> AutocompleteTextField(
onValueChange: (T) -> Unit,
modifier: Modifier = Modifier.fillMaxWidth(),
label: @Composable () -> Unit = { Text("Search") },
fetchSuggestions: suspend (query: String) -> List<T> = { emptyList() },
suggestionContent: @Composable (T) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
var selectedItem by remember { mutableStateOf<T?>(null) }
var isLoading by remember { mutableStateOf(false) }
var suggestions by remember { mutableStateOf<List<T>>(emptyList()) }
var textFieldSize by remember { mutableStateOf(Size.Zero) }
val expanded by remember(suggestions) { derivedStateOf { suggestions.isNotEmpty() } }
val coroutineScope = rememberCoroutineScope()
Box(Modifier.fillMaxWidth()) {
OutlinedTextField(
value = searchQuery,
onValueChange = { query ->
searchQuery = query
selectedItem = null
if (query.length >= 2) {
isLoading = true
coroutineScope.launch {
suggestions = fetchSuggestions(query)
isLoading = false
}
} else {
suggestions = emptyList()
}
},
label = label,
modifier = modifier.onGloballyPositioned {
textFieldSize = it.size.toSize()
},
trailingIcon = {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else if (searchQuery.isNotEmpty()) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear",
modifier = Modifier.clickable {
searchQuery = ""
suggestions = emptyList()
selectedItem = null
}
)
}
},
// isError = selectedItem == null && searchQuery.isNotEmpty()
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { suggestions = emptyList() },
modifier = with(Modifier) {
width(textFieldSize.width.dp)
.heightIn(max = 400.dp)
}
) {
suggestions.forEach { item ->
Column(Modifier.fillMaxWidth()) {
suggestionContent(item)
}
}
}
}
}

View File

@ -0,0 +1,26 @@
package net.codinux.banking.ui.model
import kotlinx.serialization.Serializable
@Serializable
class BankInfo(
val name: String,
val bankCode: String,
val bic: String = "",
val postalCode: String,
val city: String,
val pinTanAddress: String? = null,
val pinTanVersion: String? = null,
val bankingGroup: BankingGroup? = null,
val branchesInOtherCities: List<String> = listOf() // to have only one entry per bank its branches' cities are now stored in branchesInOtherCities so that branches' cities are still searchable
) {
val supportsPinTan: Boolean
get() = pinTanAddress.isNullOrEmpty() == false
val supportsFinTs3_0: Boolean
get() = pinTanVersion == "FinTS V3.0"
override fun toString() = "$bankCode $name $city"
}

View File

@ -0,0 +1,27 @@
package net.codinux.banking.ui.model
enum class BankingGroup {
Sparkasse,
DKB,
OldenburgischeLandesbank,
VolksUndRaiffeisenbanken,
Sparda,
PSD,
GLS,
SonstigeGenossenschaftsbank,
DeutscheBank,
Postbank,
Commerzbank,
Comdirect,
Unicredit,
Targobank,
ING,
Santander,
Norisbank,
Degussa,
Oberbank,
Bundesbank,
KfW,
N26,
Consors
}

View File

@ -0,0 +1,80 @@
package net.codinux.banking.ui.service
import bankmeister.composeapp.generated.resources.Res
import kotlinx.serialization.json.Json
import net.codinux.banking.ui.model.BankInfo
import org.jetbrains.compose.resources.ExperimentalResourceApi
class BankFinder {
private lateinit var bankList: List<BankInfo>
suspend fun getBankList(maxItems: Int? = null): List<BankInfo> {
if (this::bankList.isInitialized == false) {
bankList = loadBankList()
}
return bankList.take(maxItems ?: Int.MAX_VALUE)
}
suspend fun findBankByNameBankCodeOrCity(query: String?, maxItems: Int? = null): List<BankInfo> {
if (query.isNullOrBlank()) {
return getBankList(maxItems)
}
query.toIntOrNull()?.let { // if query is an integer, then it can only be an bank code, but not a bank name or city
return findBankByBankCode(query, maxItems)
}
return findBankByNameBankCodeOrCityForNonEmptyQuery(query, maxItems)
}
suspend fun findBankByBankCode(query: String, maxItems: Int?): List<BankInfo> {
if (query.isEmpty()) {
return getBankList(maxItems)
}
return getBankList().asSequence().filter { it.bankCode.startsWith(query) }
.max(maxItems)
}
suspend fun findBankByNameBankCodeOrCityForNonEmptyQuery(query: String, maxItems: Int?): List<BankInfo> {
val queryPartsLowerCase = query.lowercase().split(" ", "-")
return getBankList().asSequence().filter { bankInfo ->
checkIfAllQueryPartsMatchBankNameBankCodeOrCity(queryPartsLowerCase, bankInfo)
}
.max(maxItems)
}
private fun checkIfAllQueryPartsMatchBankNameBankCodeOrCity(queryPartsLowerCase: List<String>, bankInfo: BankInfo): Boolean {
for (queryPartLowerCase in queryPartsLowerCase) {
if (checkIfQueryMatchesBankNameBankCodeOrCity(bankInfo, queryPartLowerCase) == false) {
return false
}
}
return true
}
private fun checkIfQueryMatchesBankNameBankCodeOrCity(bankInfo: BankInfo, queryLowerCase: String): Boolean {
return bankInfo.name.lowercase().contains(queryLowerCase)
|| bankInfo.bankCode.startsWith(queryLowerCase)
|| bankInfo.city.lowercase().startsWith(queryLowerCase)
|| bankInfo.branchesInOtherCities.any { it.lowercase().startsWith(queryLowerCase) }
}
fun Sequence<BankInfo>.max(maxItems: Int? = null): List<BankInfo> =
this.take(maxItems ?: Int.MAX_VALUE)
.toList()
@OptIn(ExperimentalResourceApi::class)
private suspend fun loadBankList(): List<BankInfo> {
val json = Res.readBytes("files/BankList.json").decodeToString()
return Json.decodeFromString(json)
}
}

View File

@ -4,18 +4,24 @@ import bankmeister.composeapp.generated.resources.Res
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.ui.model.BankInfo
import net.codinux.csv.reader.CsvReader
import net.codinux.log.logger
import org.jetbrains.compose.resources.ExperimentalResourceApi
@OptIn(ExperimentalResourceApi::class)
class BankingService {
class BankingService(
private val bankFinder: BankFinder
) {
private var cachedTransactions: List<AccountTransaction>? = null
private val log by logger()
suspend fun findBanks(query: String): List<BankInfo> =
bankFinder.findBankByNameBankCodeOrCity(query, 12)
suspend fun getTransactions(): List<AccountTransaction> {
cachedTransactions?.let {
return it

View File

@ -2,6 +2,10 @@ package net.codinux.banking.ui.service
object DI {
val bankFinder = BankFinder()
val bankingService = BankingService(bankFinder)
val formatUtil = FormatUtil()
}

View File

@ -6,6 +6,7 @@ banking-client = "0.5.1-SNAPSHOT"
kcsv = "2.1.1"
klf = "1.5.1"
kotlinx-serializable = "1.7.1"
agp = "8.2.2"
android-compileSdk = "34"
@ -28,6 +29,7 @@ banking-client-model = { group = "net.codinux.banking.client", name = "banking-c
kcsv = { group = "net.codinux.csv", name = "kcsv", version.ref = "kcsv" }
klf = { group = "net.codinux.log", name = "kmp-log", version.ref = "klf" }
kotlinx-serializable = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serializable" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" }