Compare commits

..

22 Commits

Author SHA1 Message Date
dankito 41586b0148 Fixed that back button works in ExportScreen 2024-09-13 17:53:03 +02:00
dankito 56b73ca986 Reduced list item header font weight (remove it on Android completely?) 2024-09-13 17:51:53 +02:00
dankito ded5595dae Implemented searching for multiple terms by separating search terms with ',' 2024-09-12 20:09:54 +02:00
dankito 5253219565 Implemented filtering holdings 2024-09-12 15:49:43 +02:00
dankito 5029a2c3cb Implemented updating holdings 2024-09-12 13:56:08 +02:00
dankito 91d16b7e28 Updated to new data model 2024-09-12 13:33:48 +02:00
dankito 202c9217e3 Implemented persisting and deleting Holdings (but not updating yet) 2024-09-12 13:33:20 +02:00
dankito 0ff2f684ea Fixed showing ExportScreen on Desktop 2024-09-12 11:51:09 +02:00
dankito fd87598b96 Added icon for Baader Bank 2024-09-12 11:29:40 +02:00
dankito 1d58a3b9e2 Updated to new data model 2024-09-12 11:09:03 +02:00
dankito c80b4389aa Added holdings group to LazyColumn so that they also get scrolled (and don't stick to the top) 2024-09-12 11:07:55 +02:00
dankito 673ca08974 Fixed that ImageBitmap has been created very often 2024-09-12 11:06:32 +02:00
dankito bd9229bb64 Saving retrieved images to disk on Android and Desktop 2024-09-12 11:05:07 +02:00
dankito 802a96e6dd Updated to new Amount class which now has arithmetic operations 2024-09-12 04:16:29 +02:00
dankito 9f7a276cf2 Added hint that implementation status is very experimental 2024-09-12 00:52:42 +02:00
dankito 0ef8f61965 Added preview for Flickercode 2024-09-12 00:22:50 +02:00
dankito ad4c78a380 Extracted createSqliteDriver() 2024-09-12 00:22:22 +02:00
dankito 3a39c0e64f Made database handling a bit clearer 2024-09-12 00:20:28 +02:00
dankito 35624c0034 Updated to new BankingClient model that renamed lastTransactionsRetrievalTime to lastAccountUpdateTime and made fints4k an implementation detail 2024-09-12 00:17:43 +02:00
dankito 28530d63cd Implemented displaying Holdings 2024-09-12 00:16:27 +02:00
dankito d618556f5b If otherPartyName is not set showing postingText instead 2024-09-11 16:29:20 +02:00
dankito d98cbb2363 Showing other party name in bold 2024-09-11 16:27:57 +02:00
43 changed files with 737 additions and 126 deletions

View File

@ -9,11 +9,14 @@ import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.service.ImageService
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ImageService.context = this.applicationContext
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))
setContent {

View File

@ -0,0 +1,23 @@
package net.codinux.banking.ui.composables.transactions
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.ui.forms.RoundedCornersCard
@Preview
@Composable
fun HoldingListItemPreview() {
val holding1 = Holding("MUL Amundi MSCI World V", null, null, 1693, "EUR", Amount("18578.04"), Amount("16.888"), -0.35f, Amount("17944.48"), Amount("16.828"))
val holding2 = Holding("NVIDIA Corp.", null, null, 214, "EUR", Amount("21455.36"), Amount("100.18"), 8.8f, Amount("19872.04"), Amount("92.04"))
RoundedCornersCard {
Column {
HoldingListItem(holding1, false, false)
HoldingListItem(holding2, true, true)
}
}
}

View File

@ -55,5 +55,15 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall
val tanChallenge = TanChallenge(TanChallengeType.Image, ActionRequiringTan.GetTransactions, "Sie möchten eine \"Umsatzabfrage\" freigeben: Bitte bestätigen Sie den \"Startcode 80061030\" mit der Taste \"OK\".", "913", tanMethods, "SparkassenCard (Debitkarte)", tanMedia, tanImage, null, user, account)
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
}
@Preview
@Composable
fun EnterTanDialogPreview_Flickercode() {
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickercode, "902"))
val user = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank)
val tanChallenge = TanChallenge(TanChallengeType.Flickercode, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, user = user, flickerCode = FlickerCode("", ""))
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
}

View File

@ -1,12 +1,45 @@
package net.codinux.banking.ui.service
import android.content.Context
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import net.codinux.log.Log
import java.io.File
import java.net.URL
import java.security.MessageDigest
object ImageService {
lateinit var context: Context
}
private val cacheDir by lazy { File(ImageService.context.cacheDir, "imageCache").also { it.mkdirs() } }
private val messageDigest = MessageDigest.getInstance("SHA-256")
actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap =
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap()
actual suspend fun fetchBytesFromUrl(url: String): ByteArray =
URL(url).openStream().buffered().use { it.readBytes() }
actual suspend fun fetchBytesFromUrl(url: String): ByteArray {
var imageFile: File? = null
try {
val urlHash = messageDigest.digest(url.toByteArray())
.joinToString("") { "%02x".format(it) }
imageFile = File(cacheDir, urlHash)
if (imageFile.exists()) {
return imageFile.readBytes()
}
} catch (e: Throwable) {
Log.error(e) { "Could not create SHA-256 or read image bytes from file for url '$url'" }
}
val imageBytes = URL(url).openStream().buffered().use { it.readBytes() }
imageFile?.writeBytes(imageBytes)
return imageBytes
}

View File

@ -2,8 +2,10 @@ package net.codinux.banking.dataaccess
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.User
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.dataaccess.entities.UserEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -16,6 +18,13 @@ interface BankingRepository {
suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity>
suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity>
suspend fun updateHoldings(holdings: List<HoldingEntity>)
suspend fun deleteHoldings(holdings: List<HoldingEntity>)
fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel>
fun getAllAccountTransactions(): List<AccountTransactionEntity>

View File

@ -2,8 +2,10 @@ package net.codinux.banking.dataaccess
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.User
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.dataaccess.entities.UserEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -32,6 +34,17 @@ class InMemoryBankingRepository(
}
override suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity> = emptyList() // no-op
override suspend fun updateHoldings(holdings: List<HoldingEntity>) {
// no-op
}
override suspend fun deleteHoldings(holdings: List<HoldingEntity>) {
// no-op
}
override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
transactions.map { AccountTransactionViewModel(it) }

View File

@ -4,6 +4,7 @@ import app.cash.sqldelight.db.SqlDriver
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.client.model.tan.*
import net.codinux.banking.dataaccess.entities.*
import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -29,13 +30,20 @@ open class SqliteBankingRepository(
val bankAccounts = getAllBankAccounts().groupBy { it.userId }
val tanMethods = getAllTanMethods().groupBy { it.userId }
val tanMedia = getAllTanMedia().groupBy { it.userId }
val holdings = getAllHoldings().groupBy { it.bankAccountId }
return userQueries.selectAllUsers { id, bankCode, loginName, password, bankName, bic, customerName, userId, selectedTanMethodIdentifier, selectedTanMediumIdentifier, bankingGroup, serverAddress, userSetDisplayName, clientData, displayIndex, iconUrl, wrongCredentialsEntered ->
UserEntity(id, bankCode, loginName, password, bankName, bic, customerName, userId, bankAccounts[id] ?: emptyList(), selectedTanMethodIdentifier, tanMethods[id] ?: emptyList(), selectedTanMediumIdentifier, tanMedia[id] ?: emptyList(),
UserEntity(id, bankCode, loginName, password, bankName, bic, customerName, userId, getAccountsOfUser(id, bankAccounts, holdings), selectedTanMethodIdentifier, tanMethods[id] ?: emptyList(), selectedTanMediumIdentifier, tanMedia[id] ?: emptyList(),
bankingGroup?.let { BankingGroup.valueOf(it) }, serverAddress, userSetDisplayName, displayIndex.toInt(), iconUrl, wrongCredentialsEntered)
}.executeAsList()
}
protected open fun getAccountsOfUser(userId: Long, bankAccounts: Map<Long, List<BankAccountEntity>>, holdings: Map<Long, List<HoldingEntity>>): List<BankAccountEntity> {
return bankAccounts[userId].orEmpty().onEach {
it.holdings = holdings[it.id].orEmpty()
}
}
override suspend fun persistUser(user: User): UserEntity {
return userQueries.transactionWithResult {
userQueries.insertUser(user.bankCode, user.loginName, user.password, user.bankName, user.bic,
@ -55,7 +63,7 @@ open class SqliteBankingRepository(
}
fun getAllBankAccounts(): List<BankAccountEntity> = userQueries.selectAllBankAccounts { id, userId, identifier, subAccountNumber, iban, productName, accountHolderName, type, currency, accountLimit, isAccountTypeSupportedByApplication, features, balance, serverTransactionsRetentionDays, lastTransactionsRetrievalTime, retrievedTransactionsFrom, userSetDisplayName, displayIndex, hideAccount, includeInAutomaticAccountsUpdate ->
fun getAllBankAccounts(): List<BankAccountEntity> = userQueries.selectAllBankAccounts { id, userId, identifier, subAccountNumber, iban, productName, accountHolderName, type, currency, accountLimit, isAccountTypeSupportedByApplication, features, balance, serverTransactionsRetentionDays, lastAccountUpdateTime, retrievedTransactionsFrom, userSetDisplayName, displayIndex, hideAccount, includeInAutomaticAccountsUpdate ->
BankAccountEntity(
id, userId,
@ -69,9 +77,9 @@ open class SqliteBankingRepository(
mapToAmount(balance),
mapToInt(serverTransactionsRetentionDays),
mapToInstant(lastTransactionsRetrievalTime), mapToDate(retrievedTransactionsFrom),
mapToInstant(lastAccountUpdateTime), mapToDate(retrievedTransactionsFrom),
mutableListOf(), mutableListOf(),
mutableListOf(), mutableListOf(), emptyList(),
userSetDisplayName, mapToInt(displayIndex),
hideAccount, includeInAutomaticAccountsUpdate
@ -94,7 +102,7 @@ open class SqliteBankingRepository(
account.isAccountTypeSupportedByApplication, mapEnumCollectionToString(account.features),
mapInt(account.serverTransactionsRetentionDays),
mapInstant(account.lastTransactionsRetrievalTime), mapDate(account.retrievedTransactionsFrom),
mapInstant(account.lastAccountUpdateTime), mapDate(account.retrievedTransactionsFrom),
account.userSetDisplayName, mapInt(account.displayIndex),
account.hideAccount, account.includeInAutomaticAccountsUpdate
@ -106,7 +114,9 @@ open class SqliteBankingRepository(
persistTransaction(userId, accountId, transaction)
}
return BankAccountEntity(accountId, userId, account, accountTransactionEntities)
val holdings = account.holdings.map { holding -> persistHolding(userId, accountId, holding) }
return BankAccountEntity(accountId, userId, account, accountTransactionEntities, holdings)
}
@ -196,6 +206,65 @@ open class SqliteBankingRepository(
}
protected open fun getAllHoldings(): List<HoldingEntity> =
accountTransactionQueries.selectAllHoldings { id, userId, bankAccountId, name, isin, wkn, quantity, currency, totalBalance, marketValue, performancePercentage, totalCostPrice, averageCostPrice, pricingTime, buyingDate ->
HoldingEntity(id, userId, bankAccountId, name, isin, wkn, mapToInt(quantity), currency, mapToAmount(totalBalance), mapToAmount(marketValue), performancePercentage?.toFloat(), mapToAmount(totalCostPrice), mapToAmount(averageCostPrice), mapToInstant(pricingTime), mapToDate(buyingDate))
}.executeAsList()
override suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity> =
accountTransactionQueries.transactionWithResult {
holdings.map { persistHolding(bankAccount.userId, bankAccount.id, it) }
}
/**
* Has to be executed in a transaction in order that getting persisted Holding's id works~
*/
protected open suspend fun persistHolding(userId: Long, bankAccountId: Long, holding: Holding): HoldingEntity {
accountTransactionQueries.insertHolding(
userId, bankAccountId,
holding.name, holding.isin, holding.wkn,
mapInt(holding.quantity), holding.currency,
mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
holding.performancePercentage?.toDouble(),
mapAmount(holding.totalCostPrice), mapAmount(holding.averageCostPrice),
mapInstant(holding.pricingTime), mapDate(holding.buyingDate)
)
return HoldingEntity(getLastInsertedId(), userId, bankAccountId, holding)
}
override suspend fun updateHoldings(holdings: List<HoldingEntity>) {
accountTransactionQueries.transaction {
holdings.onEach { holding ->
accountTransactionQueries.updateHolding(
holding.name, holding.isin, holding.wkn,
mapInt(holding.quantity), holding.currency,
mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
holding.performancePercentage?.toDouble(),
mapAmount(holding.totalCostPrice), mapAmount(holding.averageCostPrice),
mapInstant(holding.pricingTime), mapDate(holding.buyingDate),
holding.id
)
}
}
}
override suspend fun deleteHoldings(holdings: List<HoldingEntity>) {
accountTransactionQueries.transaction {
holdings.forEach { holding ->
accountTransactionQueries.deleteHolding(holding.id)
}
}
}
override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
accountTransactionQueries.selectAllTransactionsAsViewModel { id, userId, bankAccountId, amount, currency, reference, valueDate, otherPartyName, postingText, userSetDisplayName, category ->
AccountTransactionViewModel(id, userId, bankAccountId, mapToAmount(amount), currency, reference, mapToDate(valueDate), otherPartyName, postingText, userSetDisplayName, category)
@ -328,13 +397,15 @@ open class SqliteBankingRepository(
)
@JvmName("mapAmount")
@JsName("mapAmount")
@JvmName("mapAmountNullable")
@JsName("mapAmountNullable")
private fun mapAmount(amount: Amount?): String? =
amount?.let { mapAmount(it) }
private fun mapAmount(amount: Amount): String = amount.amount
private fun mapAmount(amount: Amount): String = amount.toString()
@JvmName("mapToAmountNullable")
@JsName("mapToAmountNullable")
private fun mapToAmount(serializedAmount: String?): Amount? =
serializedAmount?.let { mapToAmount(it) }

View File

@ -3,6 +3,7 @@ package net.codinux.banking.dataaccess.entities
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.securitiesaccount.Holding
class BankAccountEntity(
val id: Long,
@ -24,11 +25,12 @@ class BankAccountEntity(
balance: Amount = Amount.Zero, // TODO: add a BigDecimal library
serverTransactionsRetentionDays: Int? = null,
lastTransactionsRetrievalTime: Instant? = null,
lastAccountUpdateTime: Instant? = null,
retrievedTransactionsFrom: LocalDate? = null,
bookedTransactions: MutableList<AccountTransactionEntity> = mutableListOf(),
prebookedTransactions: MutableList<PrebookedAccountTransaction> = mutableListOf(),
override var holdings: List<HoldingEntity> = emptyList(),
userSetDisplayName: String? = null,
displayIndex: Int = 0,
@ -44,14 +46,15 @@ class BankAccountEntity(
balance,
serverTransactionsRetentionDays, lastTransactionsRetrievalTime, retrievedTransactionsFrom,
serverTransactionsRetentionDays, lastAccountUpdateTime, retrievedTransactionsFrom,
bookedTransactions as MutableList<AccountTransaction>, prebookedTransactions,
holdings,
userSetDisplayName, displayIndex,
hideAccount, includeInAutomaticAccountsUpdate
) {
constructor(id: Long, userId: Long, account: BankAccount, transactions: List<AccountTransactionEntity> = emptyList()) : this(
constructor(id: Long, userId: Long, account: BankAccount, transactions: List<AccountTransactionEntity> = emptyList(), holdings: List<HoldingEntity> = emptyList()) : this(
id, userId,
account.identifier, account.subAccountNumber, account.iban, account.productName,
@ -63,9 +66,9 @@ class BankAccountEntity(
account.balance,
account.serverTransactionsRetentionDays,
account.lastTransactionsRetrievalTime, account.retrievedTransactionsFrom,
account.lastAccountUpdateTime, account.retrievedTransactionsFrom,
transactions.toMutableList(), mutableListOf(),
transactions.toMutableList(), mutableListOf(), holdings,
account.userSetDisplayName, account.displayIndex,
account.hideAccount, account.includeInAutomaticAccountsUpdate

View File

@ -0,0 +1,46 @@
package net.codinux.banking.dataaccess.entities
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.securitiesaccount.Holding
class HoldingEntity(
val id: Long,
val userId: Long,
val bankAccountId: Long,
name: String,
isin: String? = null,
wkn: String? = null,
quantity: Int? = null,
currency: String? = null,
totalBalance: Amount? = null,
marketValue: Amount? = null,
performancePercentage: Float? = null,
totalCostPrice: Amount? = null,
averageCostPrice: Amount? = null,
pricingTime: Instant? = null,
buyingDate: LocalDate? = null
) : Holding(name, isin, wkn, quantity, currency, totalBalance, marketValue, performancePercentage, totalCostPrice, averageCostPrice, pricingTime, buyingDate) {
constructor(id: Long, userId: Long, bankAccountId: Long, holding: Holding) : this(
id, userId, bankAccountId,
holding.name, holding.isin, holding.wkn,
holding.quantity, holding.currency,
holding.totalBalance, holding.marketValue,
holding.performancePercentage,
holding.totalCostPrice, holding.averageCostPrice,
holding.pricingTime, holding.buyingDate
)
}

View File

@ -23,5 +23,7 @@ fun ContentPane(
TransactionsList(uiState, uiSettings, isMobile)
}
StateHandler(uiState, snackbarHostState)
if (isMobile) {
StateHandler(uiState, snackbarHostState)
}
}

View File

@ -2,10 +2,11 @@ package net.codinux.banking.ui.appskeleton
import androidx.compose.foundation.layout.*
import androidx.compose.material.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.composables.StateHandler
import net.codinux.banking.ui.settings.UiSettings
import net.codinux.banking.ui.state.UiState
@ -23,6 +24,8 @@ fun DesktopLayout(
}
Column(Modifier.fillMaxSize().weight(1f).padding(start = 6.dp)) {
StateHandler(uiState, snackbarHostState)
Row(Modifier.fillMaxWidth().weight(1f)) {
ContentPane(scaffoldPadding, uiState, uiSettings, snackbarHostState, false)
}

View File

@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import net.codinux.banking.ui.composables.BanksList
import net.codinux.banking.ui.composables.NavigationMenuItem
import net.codinux.banking.ui.composables.settings.UiSettings
import net.codinux.banking.ui.composables.text.ItemDivider
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
@ -69,7 +70,7 @@ fun SideMenuContent() {
Text("Version 1.0.0 Alpha 12", color = Color.LightGray)
}
Divider(color = Colors.DrawerDivider)
ItemDivider(color = Colors.DrawerDivider)
Column(Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) {
Column(Modifier.height(ItemHeight), verticalArrangement = Arrangement.Center) {
@ -110,7 +111,7 @@ fun SideMenuContent() {
}
if (accounts.isNotEmpty()) {
Divider(color = Colors.DrawerDivider)
ItemDivider(color = Colors.DrawerDivider)
Column(Modifier.padding(16.dp)) {
UiSettings(Modifier.fillMaxWidth().padding(bottom = VerticalSpacing), textColor)

View File

@ -5,10 +5,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@ -34,14 +31,14 @@ private val bankingGroupMapper = BankingGroupMapper()
@Composable
fun BankIcon(bank: BankInfo, modifier: Modifier = Modifier, iconModifier: Modifier = DefaultIconModifier, fallbackIcon: ImageVector? = null) {
val iconUrl by remember(bank.bic) { mutableStateOf(bankIconService.findIconForBank(bank.name, bankingGroupMapper.getBankingGroup(bank.name, bank.bic))) }
val iconUrl by remember(bank.bic) { mutableStateOf(bankIconService.findIconForBank(bank.name, bank.bic, bankingGroupMapper.getBankingGroup(bank.name, bank.bic))) }
BankIcon(iconUrl, modifier, iconModifier, fallbackIcon = fallbackIcon)
}
@Composable
fun BankIcon(user: BankViewInfo?, modifier: Modifier = Modifier, iconModifier: Modifier = DefaultIconModifier, fallbackIcon: ImageVector? = null) {
val iconUrl = user?.let { bankIconService.findIconForBank(it.bankName, it.bankingGroup) }
val iconUrl = user?.let { bankIconService.findIconForBank(it.bankName, null, it.bankingGroup) }
BankIcon(iconUrl, modifier, iconModifier, fallbackIcon = fallbackIcon)
}

View File

@ -18,7 +18,7 @@ fun IconForUrl(iconUrl: String, contentDescription: String, modifier: Modifier =
val coroutineScope = rememberCoroutineScope()
if (iconUrl.endsWith(".svg") == false) { // SVG is not supported on Android
if (imageBitmap == null && iconUrl.endsWith(".svg") == false) { // SVG is not supported on Android
coroutineScope.launch(Dispatchers.IOorDefault) {
val received = imageCache.getImageBitmap(iconUrl)

View File

@ -86,7 +86,7 @@ fun NavigationMenuItem(
if (balance != null) {
Text(
formatUtil.formatAmount(balance, calculator.getTransactionsCurrency(emptyList())),
color = if (showColoredAmounts) formatUtil.getColorForAmount(balance) else textColor,
color = formatUtil.getColorForAmount(balance, showColoredAmounts),
modifier = Modifier.padding(start = 4.dp)
)
}

View File

@ -59,17 +59,24 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
LaunchedEffect(Unit) {
coroutineScope.launch {
uiState.transactionsRetrievedEvents.collect { event ->
val messagePrefix = if (event.newTransactions.isEmpty()) {
"Keine neuen Umsätze"
} else if (event.newTransactions.size == 1) {
"1 Umsatz"
var actionLabel = "Coolio"
val messagePrefix = if (event.newTransactions.size == 1) {
"1 neuer Umsatz"
} else if (event.newTransactions.size > 1) {
"${event.newTransactions.size} neue Umsätze"
} else if (event.updatedHoldings.size == 1) {
"1 Kurse aktualisiert"
} else if (event.updatedHoldings.size > 1) {
"${event.updatedHoldings.size} Kurse aktualisiert"
} else {
"${event.newTransactions.size} Umsätze"
actionLabel = "Na super"
"Keine neuen Umsätze"
}
snackbarHostState.showSnackbar(
message = "$messagePrefix für ${event.user.displayName} ${event.account.displayName}",
actionLabel = "Coolio",
actionLabel = actionLabel,
duration = SnackbarDuration.Long
)
}

View File

@ -0,0 +1,14 @@
package net.codinux.banking.ui.composables.text
import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.Style
@Composable
fun ItemDivider(color: Color = Colors.ItemDividerColor, thickness: Dp = Style.DividerThickness, modifier: Modifier = Modifier) {
Divider(color = color, thickness = thickness, modifier = modifier)
}

View File

@ -13,7 +13,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.dataaccess.entities.UserEntity
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.forms.RoundedCornersCard
import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -28,6 +30,7 @@ private val formatUtil = DI.formatUtil
fun GroupedTransactionsListItems(
modifier: Modifier,
transactionsToDisplay: List<AccountTransactionViewModel>,
holdingsToDisplay: List<Holding>,
usersById: Map<Long, UserEntity>,
transactionsGrouping: TransactionsGrouping
) {
@ -41,6 +44,35 @@ fun GroupedTransactionsListItems(
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 12.dp)) { // padding bottom = add the space the FAB sticks into the content area (= 26 - the 16 we add at the bottom of the expenses line)
if (holdingsToDisplay.isNotEmpty()) {
items(1) {
Column(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 16.dp)) {
Text(
text = "Depotwerte",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 2.dp),
)
Text("Bitte beachten: Der Abruf der Depotwerte ist sehr experimentell. Wir haben nur seitenweise Spezifikation und am Ende ein kleines Beispiel, " +
"welches sich selbst nicht an die Spezifikation hält, und keine realen Bankantworten, wir mussten es also 'blind' implementieren.",
color = Colors.Red600,
modifier = Modifier.padding(horizontal = 6.dp).padding(top = 2.dp, bottom = 4.dp)
)
RoundedCornersCard {
Column(Modifier.background(Color.White)) {
holdingsToDisplay.forEachIndexed { index, holding ->
// key(statementOfHoldings.id) {
HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1)
// }
}
}
}
}
}
}
items(groupedByDate.keys.sortedDescending()) { groupingDate ->
Column(Modifier.fillMaxWidth()) {
Text(

View File

@ -0,0 +1,104 @@
package net.codinux.banking.ui.composables.transactions
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.DefaultValues
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.ui.composables.text.ItemDivider
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style
private val uiSettings = DI.uiSettings
private val formatUtil = DI.formatUtil
private val verticalSpace = 6.dp
@Composable
fun HoldingListItem(holding: Holding, isOddItem: Boolean = false, isNotLastItem: Boolean = true, fallbackCurrency: String = DefaultValues.DefaultCurrency) {
// TODO: also regard showBalance?
val showColoredAmounts by uiSettings.showColoredAmounts.collectAsState()
val zebraStripes by uiSettings.zebraStripes.collectAsState()
val showBankIcons by uiSettings.showBankIcons.collectAsState()
val backgroundColor = if (zebraStripes && isOddItem) Colors.ZebraStripesColor else Color.White
val currency = holding.currency ?: fallbackCurrency
Column(Modifier.fillMaxWidth().background(backgroundColor).padding(horizontal = 6.dp, vertical = 6.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (showBankIcons) {
// BankIcon(user, Modifier.padding(end = 6.dp))
}
Text(
holding.name,
fontWeight = Style.ListItemHeaderWeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 4.dp)
)
Row(Modifier.width(70.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
val performance = holding.performancePercentage
if (performance != null) {
Text(
text = formatUtil.formatPercentage(performance),
color = formatUtil.getColorForAmount(Amount(performance.toString()), showColoredAmounts)
)
}
}
}
Row(Modifier.fillMaxWidth().padding(top = verticalSpace), verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.weight(1f).padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically) {
// TODO: set maxLines = 1 and TextOverflow.Ellipsis
if (holding.quantity != null) {
Text(holding.quantity.toString() + " Stück, ")
}
if (holding.averageCostPrice != null) {
Text(formatUtil.formatAmount(holding.averageCostPrice!!, currency) + "")
}
val marketValue = holding.marketValue
if (marketValue != null) {
Text(formatUtil.formatAmount(marketValue, currency), color = formatUtil.getColorForAmount(marketValue, showColoredAmounts))
}
}
Row(Modifier.widthIn(90.dp, 210.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
if (holding.totalCostPrice != null) {
Text(formatUtil.formatAmount(holding.totalCostPrice!!, currency) + "")
}
val totalBalance = holding.totalBalance
if (totalBalance != null) {
Text(formatUtil.formatAmount(totalBalance, currency), color = formatUtil.getColorForAmount(totalBalance, showColoredAmounts))
}
}
}
}
if (isNotLastItem) {
ItemDivider()
}
}

View File

@ -9,14 +9,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import net.codinux.banking.client.model.User
import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.composables.text.ItemDivider
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
@ -32,7 +35,7 @@ fun TransactionListItem(user: User?, transaction: AccountTransactionViewModel, i
val showColoredAmounts by uiSettings.showColoredAmounts.collectAsState()
val backgroundColor = if (zebraStripes && itemIndex % 2 == 1) Colors.Zinc100_50 else Color.White
val backgroundColor = if (zebraStripes && itemIndex % 2 == 1) Colors.ZebraStripesColor else Color.White
val bottomPadding = 56.dp
@ -80,8 +83,9 @@ fun TransactionListItem(user: User?, transaction: AccountTransactionViewModel, i
}
Text(
text = transaction.otherPartyName ?: "",
text = transaction.otherPartyName ?: transaction.postingText ?: "",
Modifier.fillMaxWidth(),
fontWeight = Style.ListItemHeaderWeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -126,6 +130,6 @@ fun TransactionListItem(user: User?, transaction: AccountTransactionViewModel, i
}
if (itemIndex < countItems - 1) {
Divider(color = Colors.Zinc200, thickness = 1.dp)
ItemDivider()
}
}

View File

@ -38,6 +38,12 @@ fun TransactionsList(uiState: UiState, uiSettings: UiSettings, isMobile: Boolean
derivedStateOf { filterService.filterAccounts(transactions, transactionsFilter) }
}
val holdings by uiState.holdings.collectAsState()
val holdingsToDisplay by remember(transactionsFilter, holdings) {
derivedStateOf { filterService.filterHoldings(holdings, transactionsFilter) }
}
val showBalance by uiSettings.showBalance.collectAsState()
val transactionsGrouping by uiSettings.transactionsGrouping.collectAsState()
@ -59,9 +65,15 @@ fun TransactionsList(uiState: UiState, uiSettings: UiSettings, isMobile: Boolean
}
if (transactionsGrouping != TransactionsGrouping.None) {
GroupedTransactionsListItems(transactionsListModifier, transactionsToDisplay, usersById, transactionsGrouping)
GroupedTransactionsListItems(transactionsListModifier, transactionsToDisplay, holdingsToDisplay, usersById, transactionsGrouping)
} else {
LazyColumn(transactionsListModifier, contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp)) {
itemsIndexed(holdingsToDisplay) { index, holding ->
// key(holding.isin) {
HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1)
// }
}
itemsIndexed(transactionsToDisplay) { index, transaction ->
key(transaction.id) {
TransactionListItem(usersById[transaction.userId], transaction, index, transactionsToDisplay.size)

View File

@ -52,4 +52,9 @@ object Colors {
val Emerald700 = Color(4, 120, 87)
val ZebraStripesColor = Zinc100_50
val ItemDividerColor = Colors.Zinc200
}

View File

@ -2,6 +2,7 @@ package net.codinux.banking.ui.config
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object Style {
@ -12,4 +13,9 @@ object Style {
val HeaderFontWeight: FontWeight = FontWeight.Bold
val ListItemHeaderWeight = FontWeight.Medium // couldn't believe it, the FontWeights look different on Desktop and Android
val DividerThickness = 1.dp
}

View File

@ -56,7 +56,7 @@ fun TransferMoneyDialog(
var recipientName by remember { mutableStateOf(data.recipientName ?: "") }
var recipientAccountIdentifier by remember { mutableStateOf(data.recipientAccountIdentifier ?: "") }
var amount by remember { mutableStateOf(data.amount?.amount ?: "") }
var amount by remember { mutableStateOf(data.amount.toString()) }
var paymentReference by remember { mutableStateOf(data.reference ?: "") }
val accountSupportsInstantTransfer by remember(senderAccount) { derivedStateOf { senderAccount.supportsInstantTransfer } }
var instantTransfer by remember { mutableStateOf(false) }
@ -202,10 +202,10 @@ fun TransferMoneyDialog(
minTextLengthForSearch = 0,
modifier = Modifier.weight(1f).focusRequester(amountFocus),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
getItemTitle = { suggestion -> suggestion.amount.amount },
getItemTitle = { suggestion -> suggestion.amount.toString() },
onEnteredTextChanged = { amount = it },
onSelectedItemChanged = {
amount = it?.amount?.amount ?: ""
amount = it?.amount.toString()
if (it != null) {
paymentReference = it.reference
}

View File

@ -1,5 +0,0 @@
package net.codinux.banking.ui.extensions
import net.codinux.banking.client.model.Amount
fun Amount.toBigDecimal(): Double = this.amount.toDouble()

View File

@ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.composables.text.ItemDivider
@OptIn(ExperimentalMaterialApi::class)
@Composable
@ -125,7 +125,7 @@ fun <T> AutocompleteTextField(
}
if (showDividersBetweenItems && index < suggestions.size - 1) {
Divider(color = Colors.Zinc200, thickness = 1.dp, modifier = Modifier.padding(horizontal = 8.dp))
ItemDivider(modifier = Modifier.padding(horizontal = 8.dp))
}
}
}

View File

@ -16,7 +16,7 @@ data class AccountTransactionViewModel(
val valueDate: LocalDate,
val otherPartyName: String? = null,
val bookingText: String? = null,
val postingText: String? = null,
val userSetDisplayName: String? = null,
val category: String? = null
) {

View File

@ -2,10 +2,12 @@ package net.codinux.banking.ui.model.events
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.User
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.ui.model.AccountTransactionViewModel
data class AccountTransactionsRetrievedEvent(
val user: User,
val account: BankAccount,
val newTransactions: List<AccountTransactionViewModel>
val newTransactions: List<AccountTransactionViewModel>,
val updatedHoldings: List<Holding> = emptyList()
)

View File

@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import net.codinux.banking.ui.composables.text.HeaderText
import net.codinux.banking.ui.config.Colors
@ -23,35 +25,40 @@ fun FullscreenViewBase(
onClosed: () -> Unit,
content: @Composable () -> Unit
) {
Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).padding(8.dp)) {
Dialog(
onClosed,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).padding(8.dp)) {
Row(Modifier.fillMaxWidth()) {
HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f))
Row(Modifier.fillMaxWidth()) {
HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f))
if (DI.platform.isDesktop) {
TextButton(onClosed, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.Close, contentDescription = "Close dialog", Modifier.size(32.dp))
if (DI.platform.isDesktop) {
TextButton(onClosed, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.Close, contentDescription = "Close dialog", Modifier.size(32.dp))
}
}
}
}
Column(Modifier.fillMaxWidth().weight(1f)) {
content()
}
Column(Modifier.fillMaxWidth().weight(1f)) {
content()
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
// }
//
// Spacer(Modifier.width(8.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
enabled = confirmButtonEnabled,
onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
) {
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
TextButton(
modifier = Modifier.fillMaxWidth(),
enabled = confirmButtonEnabled,
onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
) {
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
}
}
}
}

View File

@ -1,11 +1,15 @@
package net.codinux.banking.ui.service
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.dataaccess.entities.UserEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.AccountTransactionsFilter
import net.codinux.banking.ui.model.BankAccountFilter
private const val SearchTermOrSeparatorSymbol = ','
class AccountTransactionsFilterService {
fun filterAccounts(transactions: List<AccountTransactionViewModel>, filter: AccountTransactionsFilter): List<AccountTransactionViewModel> {
@ -21,11 +25,13 @@ class AccountTransactionsFilterService {
appliedAccountFilter = appliedAccountFilter.filter { it.valueDate.year == year && (month == null || it.valueDate.monthNumber == month) }
}
val searchTerm = filter.searchTerm
return if (searchTerm.isBlank()) {
val searchTerms = filter.searchTerm.split(SearchTermOrSeparatorSymbol).filter { it.isNotBlank() }
return if (searchTerms.isEmpty()) {
appliedAccountFilter
} else {
appliedAccountFilter.filter { matchesSearchTerm(it, searchTerm) }
appliedAccountFilter.filter { transaction ->
searchTerms.any { matchesSearchTerm(transaction, it) }
}
}
}
@ -43,6 +49,40 @@ class AccountTransactionsFilterService {
|| (transaction.otherPartyName != null && transaction.otherPartyName.contains(searchTerm, true))
fun filterHoldings(holdings: List<HoldingEntity>, filter: AccountTransactionsFilter): List<HoldingEntity> {
if (filter.year != null) { // TODO: check if it's current year (and month)
return emptyList()
}
var appliedHoldingFilter = if (filter.showAllAccounts) {
holdings
} else {
holdings.filter { matchesFilter(it, filter.selectedAccounts.value) }
}
val searchTerm = filter.searchTerm
return if (searchTerm.isBlank()) {
appliedHoldingFilter
} else {
appliedHoldingFilter.filter { matchesSearchTerm(it, searchTerm) }
}
}
private fun matchesFilter(holding: HoldingEntity, filter: List<BankAccountFilter>): Boolean =
filter.any { (user, bankAccount) ->
if (bankAccount != null) {
holding.bankAccountId == bankAccount.id
} else {
holding.userId == user.id
}
}
private fun matchesSearchTerm(holding: HoldingEntity, searchTerm: String): Boolean =
holding.name.contains(searchTerm, true)
|| holding.isin?.contains(searchTerm, true) == true
|| holding.wkn?.contains(searchTerm, true) == true
fun isSelected(user: UserEntity, transactionsFilter: AccountTransactionsFilter): Boolean {
if (transactionsFilter.showAllAccounts) {
return false

View File

@ -32,9 +32,9 @@ class BankDataImporterAndExporter {
private fun formatAmount(amount: Amount, decimalSeparator: Char): String =
if (decimalSeparator == '.') {
amount.amount
amount.toString()
} else {
amount.amount.replace('.', decimalSeparator)
amount.toString().replace('.', decimalSeparator)
}
}

View File

@ -5,9 +5,9 @@ import net.codinux.banking.client.model.User
class BankIconService { // TODO: extract to a common library
fun findIconForBank(user: User) = findIconForBank(user.bankName, user.bankingGroup)
fun findIconForBank(user: User) = findIconForBank(user.bankName, user.bic, user.bankingGroup)
fun findIconForBank(bankName: String, bankingGroup: BankingGroup? = null): String? = when (bankingGroup) {
fun findIconForBank(bankName: String, bic: String? = null, bankingGroup: BankingGroup? = null): String? = when (bankingGroup) {
BankingGroup.Sparkasse -> "https://sparkasse.de/favicon-32x32.png"
BankingGroup.DKB -> "https://www.ib.dkb.de/favicon.ico"
BankingGroup.OldenburgischeLandesbank -> "https://olb.de/assets/img/icon/olb/favicon-32x32.png"
@ -33,6 +33,11 @@ class BankIconService { // TODO: extract to a common library
BankingGroup.N26 -> "https://n26.de/favicon.ico"
else -> findByBicOrName(bankName, bic)
}
private fun findByBicOrName(bankName: String, bic: String?): String? = when {
bic?.startsWith("BDWBDEMM") == true || bankName.equals("Baader Bank", true) -> "https://www.baaderbank.de/favicon.ico"
else -> null // TODO: call Favicon web service
}

View File

@ -5,19 +5,17 @@ import kotlinx.coroutines.*
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.SimpleBankingClientCallback
import net.codinux.banking.client.fints4k.FinTs4kBankingClient
import net.codinux.banking.client.fints4k.FinTsClientOptions
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.model.securitiesaccount.Holding
import net.codinux.banking.client.service.BankingModelService
import net.codinux.banking.dataaccess.BankingRepository
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.UserEntity
import net.codinux.banking.fints.config.FinTsClientConfiguration
import net.codinux.banking.fints.config.FinTsClientOptions
import net.codinux.banking.dataaccess.entities.*
import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.BankInfo
@ -36,7 +34,7 @@ class BankingService(
private val bankFinder: BankFinder
) {
private val client = FinTs4kBankingClient(FinTsClientConfiguration(FinTsClientOptions(true)), SimpleBankingClientCallback { tanChallenge, callback ->
private val client = FinTs4kBankingClient(FinTsClientOptions(true, closeDialogs = false), SimpleBankingClientCallback { tanChallenge, callback ->
uiState.receivedTanChallenge(tanChallenge, callback)
})
@ -50,6 +48,7 @@ class BankingService(
uiState.users.value = getAllUsers()
uiState.transactions.value = getAllAccountTransactionsAsViewModel()
uiState.holdings.value = uiState.users.value.flatMap { it.accounts }.flatMap { it.holdings }
} catch (e: Throwable) {
log.error(e) { "Could not read all user accounts and account transactions from repository" }
}
@ -114,6 +113,7 @@ class BankingService(
uiState.users.value = users
updateTransactionsInUi(newUserEntity.accounts.flatMap { it.bookedTransactionsEntities })
updateHoldingsInUi(newUserEntity.accounts.flatMap { it.holdings }, emptyList())
} catch (e: Throwable) {
log.error(e) { "Could not save user account ${response.user}" }
}
@ -171,14 +171,52 @@ class BankingService(
emptyList()
}
val existingHoldingsByIsin = account.holdings.associateBy { it.identifier }
val retrievedHoldingsByIsin = response.holdings.associateBy { it.identifier }
val (newHoldings, updatedRetrievedHoldings) = response.holdings.partition { existingHoldingsByIsin.keys.contains(it.identifier) == false }
val (updatedExistingHoldings, deletedHoldings) = account.holdings.partition { retrievedHoldingsByIsin.keys.contains(it.identifier) }
val persistedNewHoldings = bankingRepository.persistHoldings(account, newHoldings)
bankingRepository.updateHoldings(updateHoldings(updatedExistingHoldings, updatedRetrievedHoldings))
bankingRepository.deleteHoldings(deletedHoldings)
account.holdings = account.holdings.toMutableList().apply {
addAll(persistedNewHoldings)
removeAll(deletedHoldings)
}
updateHoldingsInUi(persistedNewHoldings, deletedHoldings)
val transactionsViewModel = updateTransactionsInUi(newTransactionsEntities)
uiState.dispatchNewTransactionsRetrieved(AccountTransactionsRetrievedEvent(user, account, transactionsViewModel))
uiState.dispatchNewTransactionsRetrieved(AccountTransactionsRetrievedEvent(user, account, transactionsViewModel, response.holdings))
}
} catch (e: Throwable) {
log.error(e) { "Could not save updated account transactions for user $user" }
}
}
private fun updateHoldings(updatedExistingHoldings: List<HoldingEntity>, updatedRetrievedHoldings: List<Holding>): List<HoldingEntity> =
updatedExistingHoldings.onEach { holding ->
updatedRetrievedHoldings.firstOrNull { it.identifier == holding.identifier }?.let { retrievedHolding ->
holding.name = retrievedHolding.name
holding.isin = retrievedHolding.isin
holding.wkn = retrievedHolding.wkn
holding.quantity = retrievedHolding.quantity
holding.currency = retrievedHolding.currency
holding.totalBalance = retrievedHolding.totalBalance
holding.marketValue = retrievedHolding.marketValue
holding.performancePercentage = retrievedHolding.performancePercentage
holding.totalCostPrice = retrievedHolding.totalCostPrice
holding.averageCostPrice = retrievedHolding.averageCostPrice
holding.pricingTime = retrievedHolding.pricingTime
holding.buyingDate = retrievedHolding.buyingDate
}
}
private fun updateTransactionsInUi(addedTransactions: List<AccountTransactionEntity>): List<AccountTransactionViewModel> {
val transactionsViewModel = addedTransactions.map { AccountTransactionViewModel(it) }
@ -189,6 +227,13 @@ class BankingService(
return transactionsViewModel
}
private fun updateHoldingsInUi(persistedNewHoldings: List<HoldingEntity>, deletedHoldings: List<HoldingEntity>) {
val allHoldings = uiState.holdings.value.toMutableList()
allHoldings.removeAll(deletedHoldings)
allHoldings.addAll(persistedNewHoldings)
uiState.holdings.value = allHoldings.sortedByDescending { it.pricingTime }
}
suspend fun transferMoney(user: UserEntity, account: BankAccountEntity,
recipientName: String, recipientAccountIdentifier: String, amount: Amount, currency: String,

View File

@ -1,32 +1,26 @@
package net.codinux.banking.ui.service
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.User
import net.codinux.banking.client.model.*
import net.codinux.banking.dataaccess.entities.UserEntity
import net.codinux.banking.ui.extensions.toBigDecimal
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.AccountTransactionsFilter
class CalculatorService {
fun sumTransactions(transactions: Collection<AccountTransactionViewModel>): Amount =
// TODO: find a better solution
Amount(transactions.sumOf { it.amount.toBigDecimal() }.toString())
transactions.map { it.amount }.sum()
fun calculateBalanceOfUser(user: User): Amount =
sumAmounts(user.accounts.map { it.balance })
fun sumAmounts(amounts: Collection<Amount>): Amount =
// TODO: find a better solution
Amount(amounts.sumOf { it.toBigDecimal() }.toString())
amounts.sum()
fun sumIncome(transactions: Collection<AccountTransactionViewModel>): Amount =
// TODO: find a better solution
Amount(transactions.map { it.amount.toBigDecimal() }.filter { it > 0 }.sum().toString())
sumAmounts(transactions.map { it.amount }.filterNot { it.isNegative })
fun sumExpenses(transactions: Collection<AccountTransactionViewModel>): Amount =
// TODO: find a better solution
Amount(transactions.map { it.amount.toBigDecimal() }.filter { it < 0 }.sum().toString())
sumAmounts(transactions.map { it.amount }.filter { it.isNegative })
fun calculateBalanceOfDisplayedTransactions(transactions: Collection<AccountTransactionViewModel>, users: Collection<UserEntity>, filter: AccountTransactionsFilter): Amount {
if (filter.noFiltersApplied) {

View File

@ -3,6 +3,7 @@ package net.codinux.banking.ui.service
import androidx.compose.ui.graphics.Color
import kotlinx.datetime.*
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.isNegative
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.model.TransactionsGrouping
@ -55,8 +56,26 @@ class FormatUtil {
}
fun formatAmount(amount: Amount, currency: String): String { // TODO: find a better way
val parts = amount.amount.split('.')
fun formatAmount(amount: Amount, currency: String): String {
return "${formatToTwoDecimalPlaces(amount.toString())} ${formatCurrency(currency)}"
}
fun formatCurrency(currency: String): String = when (currency) {
"EUR" -> ""
else -> currency
}
fun getColorForAmount(amount: Amount, showColoredAmounts: Boolean = true): Color = when {
showColoredAmounts == false -> Color.Unspecified
amount.isNegative -> Colors.Red600
else -> Colors.Green600
}
fun formatPercentage(performance: Float): String =
(if (performance > 0) "+" else "") + formatToTwoDecimalPlaces(performance.toString()) + " %"
private fun formatToTwoDecimalPlaces(amount: String): String { // TODO: find a better way
val parts = amount.split('.')
var integerPart = parts[0]
val isNegative = integerPart.startsWith("-")
@ -70,19 +89,7 @@ class FormatUtil {
val decimalPart = if (parts.size == 2) parts[1] else "00"
// TODO: add thousands separator
return "$integerPart,${decimalPart.padEnd(2, '0').substring(0, 2)} ${formatCurrency(currency)}"
}
fun formatCurrency(currency: String): String = when (currency) {
"EUR" -> ""
else -> currency
}
fun getColorForAmount(amount: Amount, showColoredAmounts: Boolean = true): Color = when {
showColoredAmounts == false -> Color.Unspecified
amount.amount.startsWith("-") -> Colors.Red600
else -> Colors.Green600
return "$integerPart,${decimalPart.padEnd(2, '0').substring(0, 2)}"
}

View File

@ -45,7 +45,7 @@ class RecipientFinder(private val bankFinder: BankFinder) {
transactionsByIban = transactions.filter { it.otherPartyAccountId != null }.groupBy { it.otherPartyAccountId!! }
.mapValues { it.value.map {
PaymentDataSuggestion(it.reference ?: "", Amount(it.amount.amount.replace("-", "")), it.currency, it.valueDate)
PaymentDataSuggestion(it.reference ?: "", Amount(it.amount.toString().replace("-", "")), it.currency, it.valueDate)
}.toSet().sortedByDescending { it.valueDate } }
}

View File

@ -3,7 +3,7 @@ package net.codinux.banking.ui.service
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import net.codinux.banking.fints.extensions.minusDays
import net.codinux.banking.client.model.extensions.minusDays
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.TransactionsGrouping

View File

@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import net.codinux.banking.client.model.tan.EnterTanResult
import net.codinux.banking.client.model.tan.TanChallenge
import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.dataaccess.entities.UserEntity
import net.codinux.banking.ui.model.*
import net.codinux.banking.ui.model.error.ApplicationError
@ -21,6 +22,8 @@ class UiState : ViewModel() {
val transactions = MutableStateFlow<List<AccountTransactionViewModel>>(emptyList())
val holdings = MutableStateFlow<List<HoldingEntity>>(emptyList())
val transactionsRetrievedEvents = MutableSharedFlow<AccountTransactionsRetrievedEvent>()
suspend fun dispatchNewTransactionsRetrieved(event: AccountTransactionsRetrievedEvent) {

View File

@ -133,4 +133,87 @@ FROM AccountTransaction WHERE userId = ?;
getTransactionWithId:
SELECT AccountTransaction.*
FROM AccountTransaction WHERE id = ?;
FROM AccountTransaction WHERE id = ?;
CREATE TABLE IF NOT EXISTS Holding (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER NOT NULL,
bankAccountId INTEGER NOT NULL,
name TEXT NOT NULL,
isin TEXT,
wkn TEXT,
quantity INTEGER ,
currency TEXT,
totalBalance TEXT,
marketValue TEXT,
performancePercentage REAL,
totalCostPrice TEXT,
averageCostPrice TEXT,
pricingTime TEXT,
buyingDate TEXT
);
insertHolding:
INSERT INTO Holding(
userId, bankAccountId,
name, isin, wkn,
quantity, currency,
totalBalance, marketValue,
performancePercentage,
totalCostPrice, averageCostPrice,
pricingTime, buyingDate
)
VALUES(
?, ?,
?, ?, ?,
?, ?,
?, ?,
?,
?,?,
?, ?
);
updateHolding:
UPDATE Holding
SET
name = ?, isin = ?, wkn = ?,
quantity = ?, currency = ?,
totalBalance = ?, marketValue = ?,
performancePercentage = ?,
totalCostPrice = ?, averageCostPrice = ?,
pricingTime = ?, buyingDate = ?
WHERE id = ?;
selectAllHoldings:
SELECT Holding.*
FROM Holding;
deleteHolding:
DELETE FROM Holding WHERE id = ?;

View File

@ -99,7 +99,7 @@ CREATE TABLE IF NOT EXISTS BankAccount (
balance TEXT NOT NULL,
serverTransactionsRetentionDays INTEGER,
lastTransactionsRetrievalTime TEXT,
lastAccountUpdateTime TEXT,
retrievedTransactionsFrom TEXT,
userSetDisplayName TEXT,
@ -121,7 +121,7 @@ INSERT INTO BankAccount(
isAccountTypeSupportedByApplication, features,
serverTransactionsRetentionDays, lastTransactionsRetrievalTime, retrievedTransactionsFrom,
serverTransactionsRetentionDays, lastAccountUpdateTime, retrievedTransactionsFrom,
userSetDisplayName, displayIndex,

View File

@ -7,6 +7,7 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import bankmeister.composeapp.generated.resources.AppIcon_svg
import bankmeister.composeapp.generated.resources.Res
@ -26,22 +27,27 @@ fun main() = application {
icon = painterResource(Res.drawable.AppIcon_svg),
state = WindowState(position = WindowPosition(Alignment.Center), size = DpSize(1000.dp, 800.dp)),
) {
File("data/db").mkdirs()
DI.setRepository(JdbcSqliteDriver("jdbc:sqlite:data/db/Bankmeister.db").apply {
val schema = BankmeisterDb.Schema
schema.synchronous().also {
if (File("data/db/Bankmeister.db").exists() == false) {
it.create(this)
}
it.migrate(this, schema.version, 4)
}
})
DI.setRepository(createSqlDriverDriver())
App()
}
}
private fun createSqlDriverDriver(): SqlDriver {
File("data/db").mkdirs()
return JdbcSqliteDriver("jdbc:sqlite:data/db/Bankmeister.db").also { driver ->
BankmeisterDb.Schema.synchronous().also { schema ->
if (File("data/db/Bankmeister.db").exists() == false) {
schema.create(driver)
}
schema.migrate(driver, schema.version, 1)
}
}
}
@Preview
@Composable
fun AppPreview() {

View File

@ -2,11 +2,37 @@ package net.codinux.banking.ui.service
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import net.codinux.log.Log
import org.jetbrains.skia.Image
import java.io.File
import java.net.URL
import java.security.MessageDigest
private val cacheDir = File("data/imageCache").also { it.mkdirs() }
private val messageDigest = MessageDigest.getInstance("SHA-256")
actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap =
Image.makeFromEncoded(imageBytes).toComposeImageBitmap()
actual suspend fun fetchBytesFromUrl(url: String): ByteArray =
URL(url).openStream().buffered().use { it.readAllBytes() }
actual suspend fun fetchBytesFromUrl(url: String): ByteArray {
var imageFile: File? = null
try {
val urlHash = messageDigest.digest(url.toByteArray())
.joinToString("") { "%02x".format(it) }
imageFile = File(cacheDir, urlHash)
if (imageFile.exists()) {
return imageFile.readBytes()
}
} catch (e: Throwable) {
Log.error(e) { "Could not create SHA-256 or read image bytes from file for url '$url'" }
}
val imageBytes = URL(url).openStream().buffered().use { it.readAllBytes() }
imageFile?.writeBytes(imageBytes)
return imageBytes
}

View File

@ -63,7 +63,7 @@ class SqliteBankingRepositoryTest {
assertEquals(bankAccounts.first().balance, persistedBankAccount.balance)
assertEquals(bankAccounts.first().retrievedTransactionsFrom, persistedBankAccount.retrievedTransactionsFrom)
assertEquals(bankAccounts.first().lastTransactionsRetrievalTime, persistedBankAccount.lastTransactionsRetrievalTime)
assertEquals(bankAccounts.first().lastAccountUpdateTime, persistedBankAccount.lastAccountUpdateTime)
assertEquals(bankAccounts.first().features, persistedBankAccount.features)