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 app.cash.sqldelight.driver.android.AndroidSqliteDriver
import net.codinux.banking.dataaccess.BankmeisterDb import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.service.ImageService
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
ImageService.context = this.applicationContext
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db")) DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))
setContent { 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) 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) { }) { } EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
} }

View File

@ -1,12 +1,45 @@
package net.codinux.banking.ui.service package net.codinux.banking.ui.service
import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import net.codinux.log.Log
import java.io.File
import java.net.URL 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 = actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap =
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap() BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap()
actual suspend fun fetchBytesFromUrl(url: String): ByteArray = actual suspend fun fetchBytesFromUrl(url: String): ByteArray {
URL(url).openStream().buffered().use { it.readBytes() } 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.AccountTransaction
import net.codinux.banking.client.model.User 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.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity 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.dataaccess.entities.UserEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel 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 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 getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel>
fun getAllAccountTransactions(): List<AccountTransactionEntity> 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.AccountTransaction
import net.codinux.banking.client.model.User 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.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity 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.dataaccess.entities.UserEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel 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> = override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
transactions.map { AccountTransactionViewModel(it) } transactions.map { AccountTransactionViewModel(it) }

View File

@ -4,6 +4,7 @@ import app.cash.sqldelight.db.SqlDriver
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.* 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.client.model.tan.*
import net.codinux.banking.dataaccess.entities.* import net.codinux.banking.dataaccess.entities.*
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -29,13 +30,20 @@ open class SqliteBankingRepository(
val bankAccounts = getAllBankAccounts().groupBy { it.userId } val bankAccounts = getAllBankAccounts().groupBy { it.userId }
val tanMethods = getAllTanMethods().groupBy { it.userId } val tanMethods = getAllTanMethods().groupBy { it.userId }
val tanMedia = getAllTanMedia().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 -> 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) bankingGroup?.let { BankingGroup.valueOf(it) }, serverAddress, userSetDisplayName, displayIndex.toInt(), iconUrl, wrongCredentialsEntered)
}.executeAsList() }.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 { override suspend fun persistUser(user: User): UserEntity {
return userQueries.transactionWithResult { return userQueries.transactionWithResult {
userQueries.insertUser(user.bankCode, user.loginName, user.password, user.bankName, user.bic, 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( BankAccountEntity(
id, userId, id, userId,
@ -69,9 +77,9 @@ open class SqliteBankingRepository(
mapToAmount(balance), mapToAmount(balance),
mapToInt(serverTransactionsRetentionDays), mapToInt(serverTransactionsRetentionDays),
mapToInstant(lastTransactionsRetrievalTime), mapToDate(retrievedTransactionsFrom), mapToInstant(lastAccountUpdateTime), mapToDate(retrievedTransactionsFrom),
mutableListOf(), mutableListOf(), mutableListOf(), mutableListOf(), emptyList(),
userSetDisplayName, mapToInt(displayIndex), userSetDisplayName, mapToInt(displayIndex),
hideAccount, includeInAutomaticAccountsUpdate hideAccount, includeInAutomaticAccountsUpdate
@ -94,7 +102,7 @@ open class SqliteBankingRepository(
account.isAccountTypeSupportedByApplication, mapEnumCollectionToString(account.features), account.isAccountTypeSupportedByApplication, mapEnumCollectionToString(account.features),
mapInt(account.serverTransactionsRetentionDays), mapInt(account.serverTransactionsRetentionDays),
mapInstant(account.lastTransactionsRetrievalTime), mapDate(account.retrievedTransactionsFrom), mapInstant(account.lastAccountUpdateTime), mapDate(account.retrievedTransactionsFrom),
account.userSetDisplayName, mapInt(account.displayIndex), account.userSetDisplayName, mapInt(account.displayIndex),
account.hideAccount, account.includeInAutomaticAccountsUpdate account.hideAccount, account.includeInAutomaticAccountsUpdate
@ -106,7 +114,9 @@ open class SqliteBankingRepository(
persistTransaction(userId, accountId, transaction) 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> = override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
accountTransactionQueries.selectAllTransactionsAsViewModel { id, userId, bankAccountId, amount, currency, reference, valueDate, otherPartyName, postingText, userSetDisplayName, category -> 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) AccountTransactionViewModel(id, userId, bankAccountId, mapToAmount(amount), currency, reference, mapToDate(valueDate), otherPartyName, postingText, userSetDisplayName, category)
@ -328,13 +397,15 @@ open class SqliteBankingRepository(
) )
@JvmName("mapAmount") @JvmName("mapAmountNullable")
@JsName("mapAmount") @JsName("mapAmountNullable")
private fun mapAmount(amount: Amount?): String? = private fun mapAmount(amount: Amount?): String? =
amount?.let { mapAmount(it) } 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? = private fun mapToAmount(serializedAmount: String?): Amount? =
serializedAmount?.let { mapToAmount(it) } serializedAmount?.let { mapToAmount(it) }

View File

@ -3,6 +3,7 @@ package net.codinux.banking.dataaccess.entities
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.* import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.securitiesaccount.Holding
class BankAccountEntity( class BankAccountEntity(
val id: Long, val id: Long,
@ -24,11 +25,12 @@ class BankAccountEntity(
balance: Amount = Amount.Zero, // TODO: add a BigDecimal library balance: Amount = Amount.Zero, // TODO: add a BigDecimal library
serverTransactionsRetentionDays: Int? = null, serverTransactionsRetentionDays: Int? = null,
lastTransactionsRetrievalTime: Instant? = null, lastAccountUpdateTime: Instant? = null,
retrievedTransactionsFrom: LocalDate? = null, retrievedTransactionsFrom: LocalDate? = null,
bookedTransactions: MutableList<AccountTransactionEntity> = mutableListOf(), bookedTransactions: MutableList<AccountTransactionEntity> = mutableListOf(),
prebookedTransactions: MutableList<PrebookedAccountTransaction> = mutableListOf(), prebookedTransactions: MutableList<PrebookedAccountTransaction> = mutableListOf(),
override var holdings: List<HoldingEntity> = emptyList(),
userSetDisplayName: String? = null, userSetDisplayName: String? = null,
displayIndex: Int = 0, displayIndex: Int = 0,
@ -44,14 +46,15 @@ class BankAccountEntity(
balance, balance,
serverTransactionsRetentionDays, lastTransactionsRetrievalTime, retrievedTransactionsFrom, serverTransactionsRetentionDays, lastAccountUpdateTime, retrievedTransactionsFrom,
bookedTransactions as MutableList<AccountTransaction>, prebookedTransactions, bookedTransactions as MutableList<AccountTransaction>, prebookedTransactions,
holdings,
userSetDisplayName, displayIndex, userSetDisplayName, displayIndex,
hideAccount, includeInAutomaticAccountsUpdate 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, id, userId,
account.identifier, account.subAccountNumber, account.iban, account.productName, account.identifier, account.subAccountNumber, account.iban, account.productName,
@ -63,9 +66,9 @@ class BankAccountEntity(
account.balance, account.balance,
account.serverTransactionsRetentionDays, account.serverTransactionsRetentionDays,
account.lastTransactionsRetrievalTime, account.retrievedTransactionsFrom, account.lastAccountUpdateTime, account.retrievedTransactionsFrom,
transactions.toMutableList(), mutableListOf(), transactions.toMutableList(), mutableListOf(), holdings,
account.userSetDisplayName, account.displayIndex, account.userSetDisplayName, account.displayIndex,
account.hideAccount, account.includeInAutomaticAccountsUpdate 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) 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.foundation.layout.*
import androidx.compose.material.SnackbarHostState import androidx.compose.material.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.settings.UiSettings
import net.codinux.banking.ui.state.UiState import net.codinux.banking.ui.state.UiState
@ -23,6 +24,8 @@ fun DesktopLayout(
} }
Column(Modifier.fillMaxSize().weight(1f).padding(start = 6.dp)) { Column(Modifier.fillMaxSize().weight(1f).padding(start = 6.dp)) {
StateHandler(uiState, snackbarHostState)
Row(Modifier.fillMaxWidth().weight(1f)) { Row(Modifier.fillMaxWidth().weight(1f)) {
ContentPane(scaffoldPadding, uiState, uiSettings, snackbarHostState, false) 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.BanksList
import net.codinux.banking.ui.composables.NavigationMenuItem import net.codinux.banking.ui.composables.NavigationMenuItem
import net.codinux.banking.ui.composables.settings.UiSettings 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.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
@ -69,7 +70,7 @@ fun SideMenuContent() {
Text("Version 1.0.0 Alpha 12", color = Color.LightGray) 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.padding(horizontal = 16.dp, vertical = 24.dp)) {
Column(Modifier.height(ItemHeight), verticalArrangement = Arrangement.Center) { Column(Modifier.height(ItemHeight), verticalArrangement = Arrangement.Center) {
@ -110,7 +111,7 @@ fun SideMenuContent() {
} }
if (accounts.isNotEmpty()) { if (accounts.isNotEmpty()) {
Divider(color = Colors.DrawerDivider) ItemDivider(color = Colors.DrawerDivider)
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
UiSettings(Modifier.fillMaxWidth().padding(bottom = VerticalSpacing), textColor) 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.Icon
import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor import androidx.compose.material.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@ -34,14 +31,14 @@ private val bankingGroupMapper = BankingGroupMapper()
@Composable @Composable
fun BankIcon(bank: BankInfo, modifier: Modifier = Modifier, iconModifier: Modifier = DefaultIconModifier, fallbackIcon: ImageVector? = null) { 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) BankIcon(iconUrl, modifier, iconModifier, fallbackIcon = fallbackIcon)
} }
@Composable @Composable
fun BankIcon(user: BankViewInfo?, modifier: Modifier = Modifier, iconModifier: Modifier = DefaultIconModifier, fallbackIcon: ImageVector? = null) { 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) BankIcon(iconUrl, modifier, iconModifier, fallbackIcon = fallbackIcon)
} }

View File

@ -18,7 +18,7 @@ fun IconForUrl(iconUrl: String, contentDescription: String, modifier: Modifier =
val coroutineScope = rememberCoroutineScope() 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) { coroutineScope.launch(Dispatchers.IOorDefault) {
val received = imageCache.getImageBitmap(iconUrl) val received = imageCache.getImageBitmap(iconUrl)

View File

@ -86,7 +86,7 @@ fun NavigationMenuItem(
if (balance != null) { if (balance != null) {
Text( Text(
formatUtil.formatAmount(balance, calculator.getTransactionsCurrency(emptyList())), 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) modifier = Modifier.padding(start = 4.dp)
) )
} }

View File

@ -59,17 +59,24 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
coroutineScope.launch { coroutineScope.launch {
uiState.transactionsRetrievedEvents.collect { event -> uiState.transactionsRetrievedEvents.collect { event ->
val messagePrefix = if (event.newTransactions.isEmpty()) { var actionLabel = "Coolio"
"Keine neuen Umsätze"
} else if (event.newTransactions.size == 1) { val messagePrefix = if (event.newTransactions.size == 1) {
"1 Umsatz" "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 { } else {
"${event.newTransactions.size} Umsätze" actionLabel = "Na super"
"Keine neuen Umsätze"
} }
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = "$messagePrefix für ${event.user.displayName} ${event.account.displayName}", message = "$messagePrefix für ${event.user.displayName} ${event.account.displayName}",
actionLabel = "Coolio", actionLabel = actionLabel,
duration = SnackbarDuration.Long 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import net.codinux.banking.client.model.Amount 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.dataaccess.entities.UserEntity
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.forms.RoundedCornersCard import net.codinux.banking.ui.forms.RoundedCornersCard
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -28,6 +30,7 @@ private val formatUtil = DI.formatUtil
fun GroupedTransactionsListItems( fun GroupedTransactionsListItems(
modifier: Modifier, modifier: Modifier,
transactionsToDisplay: List<AccountTransactionViewModel>, transactionsToDisplay: List<AccountTransactionViewModel>,
holdingsToDisplay: List<Holding>,
usersById: Map<Long, UserEntity>, usersById: Map<Long, UserEntity>,
transactionsGrouping: TransactionsGrouping 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) 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 -> items(groupedByDate.keys.sortedDescending()) { groupingDate ->
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text( 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput 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.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.codinux.banking.client.model.User import net.codinux.banking.client.model.User
import net.codinux.banking.ui.composables.BankIcon 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.Colors
import net.codinux.banking.ui.config.DI 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.AccountTransactionViewModel
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData 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 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 val bottomPadding = 56.dp
@ -80,8 +83,9 @@ fun TransactionListItem(user: User?, transaction: AccountTransactionViewModel, i
} }
Text( Text(
text = transaction.otherPartyName ?: "", text = transaction.otherPartyName ?: transaction.postingText ?: "",
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
fontWeight = Style.ListItemHeaderWeight,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -126,6 +130,6 @@ fun TransactionListItem(user: User?, transaction: AccountTransactionViewModel, i
} }
if (itemIndex < countItems - 1) { 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) } 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 showBalance by uiSettings.showBalance.collectAsState()
val transactionsGrouping by uiSettings.transactionsGrouping.collectAsState() val transactionsGrouping by uiSettings.transactionsGrouping.collectAsState()
@ -59,9 +65,15 @@ fun TransactionsList(uiState: UiState, uiSettings: UiSettings, isMobile: Boolean
} }
if (transactionsGrouping != TransactionsGrouping.None) { if (transactionsGrouping != TransactionsGrouping.None) {
GroupedTransactionsListItems(transactionsListModifier, transactionsToDisplay, usersById, transactionsGrouping) GroupedTransactionsListItems(transactionsListModifier, transactionsToDisplay, holdingsToDisplay, usersById, transactionsGrouping)
} else { } else {
LazyColumn(transactionsListModifier, contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp)) { 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 -> itemsIndexed(transactionsToDisplay) { index, transaction ->
key(transaction.id) { key(transaction.id) {
TransactionListItem(usersById[transaction.userId], transaction, index, transactionsToDisplay.size) TransactionListItem(usersById[transaction.userId], transaction, index, transactionsToDisplay.size)

View File

@ -52,4 +52,9 @@ object Colors {
val Emerald700 = Color(4, 120, 87) 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.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
object Style { object Style {
@ -12,4 +13,9 @@ object Style {
val HeaderFontWeight: FontWeight = FontWeight.Bold 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 recipientName by remember { mutableStateOf(data.recipientName ?: "") }
var recipientAccountIdentifier by remember { mutableStateOf(data.recipientAccountIdentifier ?: "") } 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 ?: "") } var paymentReference by remember { mutableStateOf(data.reference ?: "") }
val accountSupportsInstantTransfer by remember(senderAccount) { derivedStateOf { senderAccount.supportsInstantTransfer } } val accountSupportsInstantTransfer by remember(senderAccount) { derivedStateOf { senderAccount.supportsInstantTransfer } }
var instantTransfer by remember { mutableStateOf(false) } var instantTransfer by remember { mutableStateOf(false) }
@ -202,10 +202,10 @@ fun TransferMoneyDialog(
minTextLengthForSearch = 0, minTextLengthForSearch = 0,
modifier = Modifier.weight(1f).focusRequester(amountFocus), modifier = Modifier.weight(1f).focusRequester(amountFocus),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
getItemTitle = { suggestion -> suggestion.amount.amount }, getItemTitle = { suggestion -> suggestion.amount.toString() },
onEnteredTextChanged = { amount = it }, onEnteredTextChanged = { amount = it },
onSelectedItemChanged = { onSelectedItemChanged = {
amount = it?.amount?.amount ?: "" amount = it?.amount.toString()
if (it != null) { if (it != null) {
paymentReference = it.reference 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 androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.composables.text.ItemDivider
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@ -125,7 +125,7 @@ fun <T> AutocompleteTextField(
} }
if (showDividersBetweenItems && index < suggestions.size - 1) { 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 valueDate: LocalDate,
val otherPartyName: String? = null, val otherPartyName: String? = null,
val bookingText: String? = null, val postingText: String? = null,
val userSetDisplayName: String? = null, val userSetDisplayName: String? = null,
val category: 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.BankAccount
import net.codinux.banking.client.model.User import net.codinux.banking.client.model.User
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
data class AccountTransactionsRetrievedEvent( data class AccountTransactionsRetrievedEvent(
val user: User, val user: User,
val account: BankAccount, 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.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import net.codinux.banking.ui.composables.text.HeaderText import net.codinux.banking.ui.composables.text.HeaderText
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
@ -23,35 +25,40 @@ fun FullscreenViewBase(
onClosed: () -> Unit, onClosed: () -> Unit,
content: @Composable () -> 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()) { Row(Modifier.fillMaxWidth()) {
HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f)) HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f))
if (DI.platform.isDesktop) { if (DI.platform.isDesktop) {
TextButton(onClosed, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) { TextButton(onClosed, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.Close, contentDescription = "Close dialog", Modifier.size(32.dp)) Icon(Icons.Filled.Close, contentDescription = "Close dialog", Modifier.size(32.dp))
}
} }
} }
}
Column(Modifier.fillMaxWidth().weight(1f)) { Column(Modifier.fillMaxWidth().weight(1f)) {
content() content()
} }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) { // TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor) // Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
// } // }
// //
// Spacer(Modifier.width(8.dp)) // Spacer(Modifier.width(8.dp))
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = confirmButtonEnabled, enabled = confirmButtonEnabled,
onClick = { /* onConfirm?.invoke() ?: */ onClosed() } onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
) { ) {
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center) Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
}
} }
} }
} }

View File

@ -1,11 +1,15 @@
package net.codinux.banking.ui.service package net.codinux.banking.ui.service
import net.codinux.banking.dataaccess.entities.BankAccountEntity 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.dataaccess.entities.UserEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.AccountTransactionsFilter import net.codinux.banking.ui.model.AccountTransactionsFilter
import net.codinux.banking.ui.model.BankAccountFilter import net.codinux.banking.ui.model.BankAccountFilter
private const val SearchTermOrSeparatorSymbol = ','
class AccountTransactionsFilterService { class AccountTransactionsFilterService {
fun filterAccounts(transactions: List<AccountTransactionViewModel>, filter: AccountTransactionsFilter): List<AccountTransactionViewModel> { 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) } appliedAccountFilter = appliedAccountFilter.filter { it.valueDate.year == year && (month == null || it.valueDate.monthNumber == month) }
} }
val searchTerm = filter.searchTerm val searchTerms = filter.searchTerm.split(SearchTermOrSeparatorSymbol).filter { it.isNotBlank() }
return if (searchTerm.isBlank()) { return if (searchTerms.isEmpty()) {
appliedAccountFilter appliedAccountFilter
} else { } 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)) || (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 { fun isSelected(user: UserEntity, transactionsFilter: AccountTransactionsFilter): Boolean {
if (transactionsFilter.showAllAccounts) { if (transactionsFilter.showAllAccounts) {
return false return false

View File

@ -32,9 +32,9 @@ class BankDataImporterAndExporter {
private fun formatAmount(amount: Amount, decimalSeparator: Char): String = private fun formatAmount(amount: Amount, decimalSeparator: Char): String =
if (decimalSeparator == '.') { if (decimalSeparator == '.') {
amount.amount amount.toString()
} else { } 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 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.Sparkasse -> "https://sparkasse.de/favicon-32x32.png"
BankingGroup.DKB -> "https://www.ib.dkb.de/favicon.ico" BankingGroup.DKB -> "https://www.ib.dkb.de/favicon.ico"
BankingGroup.OldenburgischeLandesbank -> "https://olb.de/assets/img/icon/olb/favicon-32x32.png" 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" 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 else -> null // TODO: call Favicon web service
} }

View File

@ -5,19 +5,17 @@ import kotlinx.coroutines.*
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.SimpleBankingClientCallback import net.codinux.banking.client.SimpleBankingClientCallback
import net.codinux.banking.client.fints4k.FinTs4kBankingClient 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.*
import net.codinux.banking.client.model.options.GetAccountDataOptions import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.options.RetrieveTransactions import net.codinux.banking.client.model.options.RetrieveTransactions
import net.codinux.banking.client.model.request.GetAccountDataRequest import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.request.TransferMoneyRequestForUser import net.codinux.banking.client.model.request.TransferMoneyRequestForUser
import net.codinux.banking.client.model.response.* 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.client.service.BankingModelService
import net.codinux.banking.dataaccess.BankingRepository import net.codinux.banking.dataaccess.BankingRepository
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity import net.codinux.banking.dataaccess.entities.*
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.ui.IOorDefault import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.BankInfo import net.codinux.banking.ui.model.BankInfo
@ -36,7 +34,7 @@ class BankingService(
private val bankFinder: BankFinder 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) uiState.receivedTanChallenge(tanChallenge, callback)
}) })
@ -50,6 +48,7 @@ class BankingService(
uiState.users.value = getAllUsers() uiState.users.value = getAllUsers()
uiState.transactions.value = getAllAccountTransactionsAsViewModel() uiState.transactions.value = getAllAccountTransactionsAsViewModel()
uiState.holdings.value = uiState.users.value.flatMap { it.accounts }.flatMap { it.holdings }
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Could not read all user accounts and account transactions from repository" } log.error(e) { "Could not read all user accounts and account transactions from repository" }
} }
@ -114,6 +113,7 @@ class BankingService(
uiState.users.value = users uiState.users.value = users
updateTransactionsInUi(newUserEntity.accounts.flatMap { it.bookedTransactionsEntities }) updateTransactionsInUi(newUserEntity.accounts.flatMap { it.bookedTransactionsEntities })
updateHoldingsInUi(newUserEntity.accounts.flatMap { it.holdings }, emptyList())
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Could not save user account ${response.user}" } log.error(e) { "Could not save user account ${response.user}" }
} }
@ -171,14 +171,52 @@ class BankingService(
emptyList() 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) val transactionsViewModel = updateTransactionsInUi(newTransactionsEntities)
uiState.dispatchNewTransactionsRetrieved(AccountTransactionsRetrievedEvent(user, account, transactionsViewModel)) uiState.dispatchNewTransactionsRetrieved(AccountTransactionsRetrievedEvent(user, account, transactionsViewModel, response.holdings))
} }
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Could not save updated account transactions for user $user" } 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> { private fun updateTransactionsInUi(addedTransactions: List<AccountTransactionEntity>): List<AccountTransactionViewModel> {
val transactionsViewModel = addedTransactions.map { AccountTransactionViewModel(it) } val transactionsViewModel = addedTransactions.map { AccountTransactionViewModel(it) }
@ -189,6 +227,13 @@ class BankingService(
return transactionsViewModel 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, suspend fun transferMoney(user: UserEntity, account: BankAccountEntity,
recipientName: String, recipientAccountIdentifier: String, amount: Amount, currency: String, recipientName: String, recipientAccountIdentifier: String, amount: Amount, currency: String,

View File

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

View File

@ -3,6 +3,7 @@ package net.codinux.banking.ui.service
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlinx.datetime.* import kotlinx.datetime.*
import net.codinux.banking.client.model.Amount 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.config.Colors
import net.codinux.banking.ui.model.TransactionsGrouping 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 fun formatAmount(amount: Amount, currency: String): String {
val parts = amount.amount.split('.') 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] var integerPart = parts[0]
val isNegative = integerPart.startsWith("-") val isNegative = integerPart.startsWith("-")
@ -70,19 +89,7 @@ class FormatUtil {
val decimalPart = if (parts.size == 2) parts[1] else "00" val decimalPart = if (parts.size == 2) parts[1] else "00"
// TODO: add thousands separator return "$integerPart,${decimalPart.padEnd(2, '0').substring(0, 2)}"
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
} }

View File

@ -45,7 +45,7 @@ class RecipientFinder(private val bankFinder: BankFinder) {
transactionsByIban = transactions.filter { it.otherPartyAccountId != null }.groupBy { it.otherPartyAccountId!! } transactionsByIban = transactions.filter { it.otherPartyAccountId != null }.groupBy { it.otherPartyAccountId!! }
.mapValues { it.value.map { .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 } } }.toSet().sortedByDescending { it.valueDate } }
} }

View File

@ -3,7 +3,7 @@ package net.codinux.banking.ui.service
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month 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.AccountTransactionViewModel
import net.codinux.banking.ui.model.TransactionsGrouping import net.codinux.banking.ui.model.TransactionsGrouping

View File

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

View File

@ -133,4 +133,87 @@ FROM AccountTransaction WHERE userId = ?;
getTransactionWithId: getTransactionWithId:
SELECT AccountTransaction.* 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, balance TEXT NOT NULL,
serverTransactionsRetentionDays INTEGER, serverTransactionsRetentionDays INTEGER,
lastTransactionsRetrievalTime TEXT, lastAccountUpdateTime TEXT,
retrievedTransactionsFrom TEXT, retrievedTransactionsFrom TEXT,
userSetDisplayName TEXT, userSetDisplayName TEXT,
@ -121,7 +121,7 @@ INSERT INTO BankAccount(
isAccountTypeSupportedByApplication, features, isAccountTypeSupportedByApplication, features,
serverTransactionsRetentionDays, lastTransactionsRetrievalTime, retrievedTransactionsFrom, serverTransactionsRetentionDays, lastAccountUpdateTime, retrievedTransactionsFrom,
userSetDisplayName, displayIndex, userSetDisplayName, displayIndex,

View File

@ -7,6 +7,7 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import app.cash.sqldelight.async.coroutines.synchronous import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import bankmeister.composeapp.generated.resources.AppIcon_svg import bankmeister.composeapp.generated.resources.AppIcon_svg
import bankmeister.composeapp.generated.resources.Res import bankmeister.composeapp.generated.resources.Res
@ -26,22 +27,27 @@ fun main() = application {
icon = painterResource(Res.drawable.AppIcon_svg), icon = painterResource(Res.drawable.AppIcon_svg),
state = WindowState(position = WindowPosition(Alignment.Center), size = DpSize(1000.dp, 800.dp)), state = WindowState(position = WindowPosition(Alignment.Center), size = DpSize(1000.dp, 800.dp)),
) { ) {
File("data/db").mkdirs() DI.setRepository(createSqlDriverDriver())
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)
}
})
App() 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 @Preview
@Composable @Composable
fun AppPreview() { 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.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import net.codinux.log.Log
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.io.File
import java.net.URL 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 = actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap =
Image.makeFromEncoded(imageBytes).toComposeImageBitmap() Image.makeFromEncoded(imageBytes).toComposeImageBitmap()
actual suspend fun fetchBytesFromUrl(url: String): ByteArray = actual suspend fun fetchBytesFromUrl(url: String): ByteArray {
URL(url).openStream().buffered().use { it.readAllBytes() } 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().balance, persistedBankAccount.balance)
assertEquals(bankAccounts.first().retrievedTransactionsFrom, persistedBankAccount.retrievedTransactionsFrom) assertEquals(bankAccounts.first().retrievedTransactionsFrom, persistedBankAccount.retrievedTransactionsFrom)
assertEquals(bankAccounts.first().lastTransactionsRetrievalTime, persistedBankAccount.lastTransactionsRetrievalTime) assertEquals(bankAccounts.first().lastAccountUpdateTime, persistedBankAccount.lastAccountUpdateTime)
assertEquals(bankAccounts.first().features, persistedBankAccount.features) assertEquals(bankAccounts.first().features, persistedBankAccount.features)