Implemented searching for banks in AddAccountDialog
This commit is contained in:
parent
5f25b51487
commit
972af95a11
|
@ -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
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -2,6 +2,10 @@ package net.codinux.banking.ui.service
|
|||
|
||||
object DI {
|
||||
|
||||
val bankFinder = BankFinder()
|
||||
|
||||
val bankingService = BankingService(bankFinder)
|
||||
|
||||
val formatUtil = FormatUtil()
|
||||
|
||||
}
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in New Issue