Compare commits
152 Commits
Author | SHA1 | Date |
---|---|---|
dankito | ed67f2c772 | |
dankito | 62b750c4f3 | |
dankito | 395c29a63d | |
dankito | 91ea593fe1 | |
dankito | 81a98af43f | |
dankito | 04e78b042e | |
dankito | 0c87d99d77 | |
dankito | b5116604c1 | |
dankito | 98f15d3a8d | |
dankito | b7cb22e7a8 | |
dankito | 6af5ef2529 | |
dankito | 350a18c2a3 | |
dankito | 2c32c1970c | |
dankito | a5b4540443 | |
dankito | 166219d7e3 | |
dankito | 5af6cc82fc | |
dankito | d30337eef2 | |
dankito | bf81a9854d | |
dankito | 5965a1e4dd | |
dankito | e16d71a1fa | |
dankito | f87375b8dd | |
dankito | 43f15fa662 | |
dankito | 1c7fa49de5 | |
dankito | f547b76c7b | |
dankito | 0a46dd931f | |
dankito | 39c9006809 | |
dankito | 8324ac0101 | |
dankito | 3a40167e2f | |
dankito | c3655e38ea | |
dankito | bca359d4ed | |
dankito | fe3a97377f | |
dankito | 9c9d52f03e | |
dankito | b518f4c0ee | |
dankito | fe853f03e9 | |
dankito | 02d8d14ede | |
dankito | 04fa2dcbb4 | |
dankito | e0e150e53a | |
dankito | 54ca55e2f9 | |
dankito | 54e9a70122 | |
dankito | 74d42abce3 | |
dankito | 41a6bef7d2 | |
dankito | 8c24b83ecf | |
dankito | 466ab84c36 | |
dankito | 12c9becd17 | |
dankito | 390d529be0 | |
dankito | cd8f8a32e6 | |
dankito | a532130fcb | |
dankito | 08d6e62a38 | |
dankito | a2d752aca1 | |
dankito | a58bd1d2ce | |
dankito | 052ee9c7e5 | |
dankito | 1228b6884d | |
dankito | 1ed96fce7d | |
dankito | e4a8a79ee3 | |
dankito | 20fdc8dece | |
dankito | f107d947ff | |
dankito | e1bb7722ff | |
dankito | 316a0027f7 | |
dankito | d47bc46cf8 | |
dankito | fbd9c9485a | |
dankito | fd53b2f005 | |
dankito | 5d00bbf77e | |
dankito | c89220bc0c | |
dankito | e3f9c78b95 | |
dankito | 1520d19625 | |
dankito | 3d474f38ae | |
dankito | 737d35b9a6 | |
dankito | 315e05b08e | |
dankito | b802f5b48f | |
dankito | 0dac13dc43 | |
dankito | 802bab9c38 | |
dankito | d12cb7269b | |
dankito | e3a6cd7df2 | |
dankito | 359f453543 | |
dankito | 14cb9c789c | |
dankito | b805a070eb | |
dankito | 3f5527a0fd | |
dankito | 36d5e0a36a | |
dankito | ebbdd56418 | |
dankito | 4cdc573364 | |
dankito | 0ac9c7155d | |
dankito | db2a75fba7 | |
dankito | 2ba1b52a80 | |
dankito | 1970eff09a | |
dankito | e896fbb3cc | |
dankito | 3259c079b4 | |
dankito | ba8b475eaf | |
dankito | 0aa25e0c59 | |
dankito | 5ac65308bb | |
dankito | ee3faa7de1 | |
dankito | 3eb9c9fd95 | |
dankito | b13be27a2d | |
dankito | 6ac2faf207 | |
dankito | 51fe6d621d | |
dankito | 4fbc52542d | |
dankito | 0a0b93f9c8 | |
dankito | a47f580594 | |
dankito | 74e144de59 | |
dankito | 2809a4b149 | |
dankito | 859a9d6b7a | |
dankito | 18ea0e35f1 | |
dankito | bbfc591e5b | |
dankito | 4531380bac | |
dankito | c6f4b6d250 | |
dankito | 5a0ade46b2 | |
dankito | 4d7cca7a7e | |
dankito | 97282adf12 | |
dankito | 3c2eb3d4d7 | |
dankito | a88ddbdd16 | |
dankito | b86c59ef24 | |
dankito | 33b93c170f | |
dankito | 78edbd6d72 | |
dankito | f365bfd883 | |
dankito | c6f93e1d85 | |
dankito | af3dd02509 | |
dankito | 3b72d95234 | |
dankito | abc9ceb29e | |
dankito | b5dbf92b9b | |
dankito | d549b96e7b | |
dankito | b384f6bc00 | |
dankito | 5d0669c5fe | |
dankito | 6a8b913bc4 | |
dankito | 7d9a2695a9 | |
dankito | 7ce76d73ea | |
dankito | 1f19da85f3 | |
dankito | 8707c5e3d7 | |
dankito | ff8bf80f6d | |
dankito | df093d0cd3 | |
dankito | 08e3096892 | |
dankito | 931d41d610 | |
dankito | fc0d2642e5 | |
dankito | 7712102af2 | |
dankito | 6564a9d33d | |
dankito | 0f89314ba3 | |
dankito | 4fa7adeeb1 | |
dankito | f5a93bdddd | |
dankito | 4a5748b813 | |
dankito | d447f2991c | |
dankito | ba156a8512 | |
dankito | 41c2b89c34 | |
dankito | 607eb4c2f5 | |
dankito | 9412f6b7f0 | |
dankito | 4697119c58 | |
dankito | 6e6449e956 | |
dankito | f1c4c8ca13 | |
dankito | a50f55daff | |
dankito | db8d4a7dcd | |
dankito | 2813224eff | |
dankito | d98a77bc1d | |
dankito | 372a259f8b | |
dankito | da9184edea | |
dankito | ba3d0c4d30 |
|
@ -21,5 +21,8 @@ xcuserdata
|
|||
!*.xcodeproj/project.xcworkspace/
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
**/*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
|
||||
|
||||
composeApp/release/
|
||||
composeApp/data/
|
||||
BankingPersistence/data/
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
|
||||
alias(libs.plugins.androidLibrary)
|
||||
|
||||
alias(libs.plugins.sqldelight)
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(11)
|
||||
|
||||
jvm()
|
||||
|
||||
js {
|
||||
moduleName = "BankingPersistence"
|
||||
binaries.executable()
|
||||
|
||||
browser()
|
||||
}
|
||||
|
||||
androidTarget {
|
||||
|
||||
}
|
||||
|
||||
|
||||
listOf(
|
||||
iosX64(),
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "BankingPersistence"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultHierarchyTemplate()
|
||||
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.banking.client.model)
|
||||
implementation(libs.fints4k.banking.client)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
implementation(libs.sqldelight.runtime)
|
||||
implementation(libs.sqldelight.coroutines.extensions)
|
||||
implementation(libs.sqldelight.paging.extensions)
|
||||
|
||||
implementation(libs.klf)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
|
||||
implementation(libs.coroutines.test)
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
implementation(libs.kotlin.test.junit)
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(libs.sqldelight.android.driver)
|
||||
}
|
||||
|
||||
iosMain.dependencies {
|
||||
implementation(libs.sqldelight.native.driver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("BankmeisterDb") {
|
||||
packageName.set("net.codinux.banking.persistence")
|
||||
generateAsync = true
|
||||
|
||||
schemaOutputDirectory = file("src/commonMain/sqldelight/databases")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace = "net.codinux.banking.persistence"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
||||
// sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
// proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package net.codinux.banking.persistence
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AndroidContext {
|
||||
lateinit var applicationContext: Context
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package net.codinux.banking.persistence
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.synchronous
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlSchema
|
||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||
|
||||
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver =
|
||||
AndroidSqliteDriver(schema.synchronous(), AndroidContext.applicationContext, dbName)
|
|
@ -0,0 +1,69 @@
|
|||
package net.codinux.banking.persistence
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.client.model.AccountTransaction
|
||||
import net.codinux.banking.client.model.Amount
|
||||
import net.codinux.banking.client.model.BankAccess
|
||||
import net.codinux.banking.client.model.securitiesaccount.Holding
|
||||
import net.codinux.banking.persistence.entities.AccountTransactionEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.persistence.entities.HoldingEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.UiSettingsEntity
|
||||
import net.codinux.banking.ui.model.AccountTransactionViewModel
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
import net.codinux.banking.ui.model.settings.ImageSettings
|
||||
|
||||
interface BankingRepository {
|
||||
|
||||
fun getAppSettings(): AppSettings?
|
||||
|
||||
suspend fun saveAppSettings(settings: AppSettings)
|
||||
|
||||
fun getUiSettings(): UiSettingsEntity?
|
||||
|
||||
suspend fun saveUiSettings(settings: UiSettingsEntity)
|
||||
|
||||
|
||||
fun getImageSettings(id: String): ImageSettings?
|
||||
|
||||
suspend fun saveImageSettings(settings: ImageSettings)
|
||||
|
||||
|
||||
fun getAllBanks(): List<BankAccessEntity>
|
||||
|
||||
suspend fun persistBank(bank: BankAccess): BankAccessEntity
|
||||
|
||||
suspend fun updateBank(bank: BankAccessEntity, loginName: String, password: String, bankName: String?)
|
||||
|
||||
suspend fun updateBank(bank: BankAccessEntity, serializedClientData: String?)
|
||||
|
||||
suspend fun updateAccount(account: BankAccountEntity, userSetDisplayName: String?, hideAccount: Boolean, includeInAutomaticAccountsUpdate: Boolean)
|
||||
|
||||
suspend fun updateAccount(account: BankAccountEntity, balance: Amount, lastAccountUpdateTime: Instant, retrievedTransactionsFrom: LocalDate?)
|
||||
|
||||
suspend fun deleteBank(bank: BankAccessEntity)
|
||||
|
||||
|
||||
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>
|
||||
|
||||
fun getAllTransactionsForBank(bank: BankAccessEntity): List<AccountTransactionEntity>
|
||||
|
||||
fun getTransactionById(transactionId: Long): AccountTransactionEntity?
|
||||
|
||||
suspend fun updateTransaction(transaction: AccountTransactionEntity, userSetOtherPartyName: String?, userSetReference: String?, notes: String?)
|
||||
|
||||
}
|
|
@ -1,15 +1,20 @@
|
|||
package net.codinux.banking.dataaccess
|
||||
package net.codinux.banking.persistence
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.client.model.AccountTransaction
|
||||
import net.codinux.banking.client.model.Amount
|
||||
import net.codinux.banking.client.model.BankAccess
|
||||
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.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.AccountTransactionEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.persistence.entities.HoldingEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.UiSettingsEntity
|
||||
import net.codinux.banking.ui.model.AccountTransactionViewModel
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
import net.codinux.banking.ui.settings.UiSettings
|
||||
import net.codinux.banking.ui.model.settings.ImageSettings
|
||||
import net.codinux.banking.ui.model.settings.TransactionsGrouping
|
||||
|
||||
class InMemoryBankingRepository(
|
||||
banks: Collection<BankAccess> = emptyList(),
|
||||
|
@ -23,7 +28,9 @@ class InMemoryBankingRepository(
|
|||
|
||||
private val transactions = transactions.map { map(it) }.toMutableList()
|
||||
|
||||
private lateinit var uiSettings: UiSettings
|
||||
private var uiSettings: UiSettingsEntity = UiSettingsEntity(true, TransactionsGrouping.Month, true, true, true)
|
||||
|
||||
private var imageSettings = mutableMapOf<String, ImageSettings>()
|
||||
|
||||
|
||||
override fun getAppSettings(): AppSettings? = appSettings
|
||||
|
@ -32,12 +39,17 @@ class InMemoryBankingRepository(
|
|||
this.appSettings = settings
|
||||
}
|
||||
|
||||
override fun getUiSettings(settings: UiSettings) {
|
||||
override fun getUiSettings() = this.uiSettings
|
||||
|
||||
override suspend fun saveUiSettings(settings: UiSettingsEntity) {
|
||||
this.uiSettings = settings
|
||||
}
|
||||
|
||||
override suspend fun saveUiSettings(settings: UiSettings) {
|
||||
this.uiSettings = settings
|
||||
|
||||
override fun getImageSettings(id: String) = imageSettings[id]
|
||||
|
||||
override suspend fun saveImageSettings(settings: ImageSettings) {
|
||||
imageSettings[settings.id] = settings
|
||||
}
|
||||
|
||||
|
||||
|
@ -49,6 +61,27 @@ class InMemoryBankingRepository(
|
|||
return entity
|
||||
}
|
||||
|
||||
override suspend fun updateBank(bank: BankAccessEntity, loginName: String, password: String, bankName: String?) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override suspend fun updateBank(bank: BankAccessEntity, serializedClientData: String?) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override suspend fun updateAccount(account: BankAccountEntity, userSetDisplayName: String?, hideAccount: Boolean, includeInAutomaticAccountsUpdate: Boolean) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override suspend fun updateAccount(account: BankAccountEntity, balance: Amount, lastAccountUpdateTime: Instant, retrievedTransactionsFrom: LocalDate?) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override suspend fun deleteBank(bank: BankAccessEntity) {
|
||||
this.banks.remove(bank)
|
||||
}
|
||||
|
||||
|
||||
override suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity> {
|
||||
throw NotImplementedError("Lazy developer, method is not implemented")
|
||||
}
|
||||
|
@ -76,6 +109,10 @@ class InMemoryBankingRepository(
|
|||
override fun getTransactionById(transactionId: Long): AccountTransactionEntity? =
|
||||
getAllAccountTransactions().firstOrNull { it.id == transactionId }
|
||||
|
||||
override suspend fun updateTransaction(transaction: AccountTransactionEntity, userSetOtherPartyName: String?, userSetReference: String?, notes: String?) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
|
||||
private fun map(bank: BankAccess) = BankAccessEntity(
|
||||
nextId++,
|
|
@ -1,25 +1,53 @@
|
|||
package net.codinux.banking.dataaccess
|
||||
package net.codinux.banking.persistence
|
||||
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlSchema
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.client.model.*
|
||||
import net.codinux.banking.client.model.AccountTransaction
|
||||
import net.codinux.banking.client.model.Amount
|
||||
import net.codinux.banking.client.model.BankAccess
|
||||
import net.codinux.banking.client.model.BankAccount
|
||||
import net.codinux.banking.client.model.BankAccountFeatures
|
||||
import net.codinux.banking.client.model.BankAccountType
|
||||
import net.codinux.banking.client.model.BankingGroup
|
||||
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.client.model.tan.AllowedTanFormat
|
||||
import net.codinux.banking.client.model.tan.MobilePhoneTanMedium
|
||||
import net.codinux.banking.client.model.tan.TanGeneratorTanMedium
|
||||
import net.codinux.banking.client.model.tan.TanMedium
|
||||
import net.codinux.banking.client.model.tan.TanMediumStatus
|
||||
import net.codinux.banking.client.model.tan.TanMediumType
|
||||
import net.codinux.banking.client.model.tan.TanMethod
|
||||
import net.codinux.banking.client.model.tan.TanMethodType
|
||||
import net.codinux.banking.client.fints4k.FinTs4kMapper
|
||||
import net.codinux.banking.persistence.entities.*
|
||||
import net.codinux.banking.ui.model.AccountTransactionViewModel
|
||||
import net.codinux.banking.ui.model.TransactionsGrouping
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
import net.codinux.banking.ui.model.settings.*
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
import net.codinux.banking.ui.settings.UiSettings
|
||||
import net.codinux.banking.ui.model.settings.ImageSettings
|
||||
import net.codinux.log.logger
|
||||
import kotlin.enums.EnumEntries
|
||||
import kotlin.js.JsName
|
||||
import kotlin.jvm.JvmName
|
||||
|
||||
open class SqliteBankingRepository(
|
||||
sqlDriver: SqlDriver
|
||||
) : BankingRepository {
|
||||
|
||||
expect fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver
|
||||
|
||||
|
||||
open class SqliteBankingRepository : BankingRepository {
|
||||
|
||||
companion object {
|
||||
val TanMethodTypesToMigrate = mapOf(
|
||||
"ChipTanManuell" to TanMethodType.ChipTanManual.name,
|
||||
"ChipTanFlickercode" to TanMethodType.ChipTanFlickerCode.name
|
||||
)
|
||||
}
|
||||
|
||||
private val schema = BankmeisterDb.Schema
|
||||
|
||||
private val sqlDriver = createSqlDriverDriver("Bankmeister.db", schema, 2L)
|
||||
|
||||
private val database = BankmeisterDb(sqlDriver)
|
||||
|
||||
|
@ -52,18 +80,30 @@ open class SqliteBankingRepository(
|
|||
}
|
||||
|
||||
|
||||
override fun getUiSettings(settings: UiSettings) {
|
||||
settingsQueries.getUiSettings { _, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors ->
|
||||
settings.transactionsGrouping.value = mapToEnum(transactionsGrouping, TransactionsGrouping.entries)
|
||||
settings.showBalance.value = showBalance
|
||||
settings.showBankIcons.value = showBankIcons
|
||||
settings.showColoredAmounts.value = showColoredAmounts
|
||||
settings.showTransactionsInAlternatingColors.value = showTransactionsInAlternatingColors
|
||||
override fun getUiSettings(): UiSettingsEntity? {
|
||||
return settingsQueries.getUiSettings { _, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors ->
|
||||
UiSettingsEntity(
|
||||
showBalance,
|
||||
mapToEnum(transactionsGrouping, TransactionsGrouping.entries),
|
||||
showTransactionsInAlternatingColors,
|
||||
showBankIcons,
|
||||
showColoredAmounts
|
||||
)
|
||||
}.executeAsOneOrNull()
|
||||
}
|
||||
|
||||
override suspend fun saveUiSettings(settings: UiSettings) {
|
||||
settingsQueries.upsertUiSettings(mapEnum(settings.transactionsGrouping.value), settings.showBalance.value, settings.showBankIcons.value, settings.showColoredAmounts.value, settings.showTransactionsInAlternatingColors.value)
|
||||
override suspend fun saveUiSettings(settings: UiSettingsEntity) {
|
||||
settingsQueries.upsertUiSettings(mapEnum(settings.transactionsGrouping), settings.showBalance, settings.showBankIcons, settings.showColoredAmounts, settings.showTransactionsInAlternatingColors)
|
||||
}
|
||||
|
||||
|
||||
override fun getImageSettings(id: String): ImageSettings? =
|
||||
settingsQueries.getImageSettings(id) { height, frequency ->
|
||||
ImageSettings(id, mapToInt(height), mapToInt(frequency))
|
||||
}.executeAsOneOrNull()
|
||||
|
||||
override suspend fun saveImageSettings(settings: ImageSettings) {
|
||||
settingsQueries.upsertImageSettings(settings.id, mapInt(settings.height), mapInt(settings.frequency))
|
||||
}
|
||||
|
||||
|
||||
|
@ -73,9 +113,9 @@ open class SqliteBankingRepository(
|
|||
val tanMedia = getAllTanMedia().groupBy { it.bankId }.mapValues { it.value.toMutableList() }
|
||||
val holdings = getAllHoldings().groupBy { it.accountId }
|
||||
|
||||
return bankQueries.getAllBanks { id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, selectedTanMethodIdentifier, selectedTanMediumIdentifier, bankingGroup, serverAddress, countryCode, userSetDisplayName, clientData, displayIndex, iconUrl, wrongCredentialsEntered ->
|
||||
return bankQueries.getAllBanks { id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, selectedTanMethodIdentifier, selectedTanMediumIdentifier, bankingGroup, serverAddress, countryCode, serializedClientData, userSetDisplayName, displayIndex, iconUrl, wrongCredentialsEntered ->
|
||||
BankAccessEntity(id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, getAccountsOfBank(id, bankAccounts, holdings), selectedTanMethodIdentifier, tanMethods[id] ?: mutableListOf(), selectedTanMediumIdentifier, tanMedia[id] ?: mutableListOf(),
|
||||
bankingGroup?.let { BankingGroup.valueOf(it) }, serverAddress, countryCode, userSetDisplayName, displayIndex.toInt(), iconUrl, wrongCredentialsEntered)
|
||||
bankingGroup?.let { BankingGroup.valueOf(it) }, serverAddress, countryCode, userSetDisplayName, displayIndex.toInt(), iconUrl, wrongCredentialsEntered, null, serializedClientData)
|
||||
}.executeAsList()
|
||||
}
|
||||
|
||||
|
@ -89,7 +129,7 @@ open class SqliteBankingRepository(
|
|||
return bankQueries.transactionWithResult {
|
||||
bankQueries.insertBank(bank.domesticBankCode, bank.loginName, bank.password, bank.bankName, bank.bic,
|
||||
bank.customerName, bank.userId, bank.selectedTanMethodIdentifier, bank.selectedTanMediumIdentifier,
|
||||
bank.bankingGroup?.name, bank.serverAddress, bank.countryCode, null, bank.userSetDisplayName, bank.displayIndex.toLong(), bank.iconUrl, bank.wrongCredentialsEntered
|
||||
bank.bankingGroup?.name, bank.serverAddress, bank.countryCode, bank.serializedClientData, bank.userSetDisplayName, bank.displayIndex.toLong(), bank.iconUrl, bank.wrongCredentialsEntered
|
||||
)
|
||||
|
||||
val bankId = getLastInsertedId() // getLastInsertedId() / last_insert_rowid() has to be called in a transaction with the insert operation, otherwise it will not work
|
||||
|
@ -103,6 +143,58 @@ open class SqliteBankingRepository(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun updateBank(bank: BankAccessEntity, loginName: String, password: String, userSetDisplayName: String?) {
|
||||
bankQueries.transaction {
|
||||
if (bank.loginName != loginName) {
|
||||
bankQueries.updateBankLoginName(loginName, bank.id)
|
||||
}
|
||||
|
||||
if (bank.password != password) {
|
||||
bankQueries.updateBankPassword(password, bank.id)
|
||||
}
|
||||
|
||||
if (bank.userSetDisplayName != userSetDisplayName) {
|
||||
bankQueries.updateBankUserSetDisplayName(userSetDisplayName, bank.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateBank(bank: BankAccessEntity, serializedClientData: String?) {
|
||||
bankQueries.transaction {
|
||||
if (serializedClientData != null) {
|
||||
bankQueries.updateBankClientData(serializedClientData, bank.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateAccount(account: BankAccountEntity, userSetDisplayName: String?, hideAccount: Boolean, includeInAutomaticAccountsUpdate: Boolean) {
|
||||
bankQueries.transaction {
|
||||
if (account.userSetDisplayName != userSetDisplayName) {
|
||||
bankQueries.updateBankAccountUserSetDisplayName(userSetDisplayName, account.id)
|
||||
}
|
||||
|
||||
if (account.hideAccount != hideAccount) {
|
||||
bankQueries.updateBankAccountHideAccount(hideAccount, account.id)
|
||||
}
|
||||
|
||||
if (account.includeInAutomaticAccountsUpdate != includeInAutomaticAccountsUpdate) {
|
||||
bankQueries.updateBankAccountIncludeInAutomaticAccountsUpdate(includeInAutomaticAccountsUpdate, account.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateAccount(account: BankAccountEntity, balance: Amount, lastAccountUpdateTime: Instant, retrievedTransactionsFrom: LocalDate?) {
|
||||
bankQueries.updateBankAccount(mapAmount(balance), mapInstant(lastAccountUpdateTime), mapDate(retrievedTransactionsFrom), account.id)
|
||||
}
|
||||
|
||||
override suspend fun deleteBank(bank: BankAccessEntity) {
|
||||
bankQueries.transaction {
|
||||
accountTransactionQueries.deleteTransactionsByBankId(bankId = bank.id)
|
||||
|
||||
bankQueries.deleteBank(bank.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getAllBankAccounts(): List<BankAccountEntity> = bankQueries.getAllBankAccounts { id, bankId, identifier, subAccountNumber, iban, productName, accountHolderName, type, currency, accountLimit, isAccountTypeSupportedByApplication, features, balance, serverTransactionsRetentionDays, lastAccountUpdateTime, retrievedTransactionsFrom, userSetDisplayName, displayIndex, hideAccount, includeInAutomaticAccountsUpdate ->
|
||||
BankAccountEntity(
|
||||
|
@ -167,7 +259,7 @@ open class SqliteBankingRepository(
|
|||
bankId,
|
||||
|
||||
displayName,
|
||||
mapToEnum(type, TanMethodType.entries),
|
||||
mapToEnum(type, TanMethodType.entries, FinTs4kMapper.TanMethodTypesToMigrate),
|
||||
identifier,
|
||||
mapToInt(maxTanInputLength),
|
||||
mapToEnum(allowedTanFormat, AllowedTanFormat.entries),
|
||||
|
@ -257,7 +349,7 @@ open class SqliteBankingRepository(
|
|||
|
||||
protected open fun getAllHoldings(): List<HoldingEntity> =
|
||||
accountTransactionQueries.selectAllHoldings { id, bankId, accountId, name, isin, wkn, quantity, currency, totalBalance, marketValue, performancePercentage, totalCostPrice, averageCostPrice, pricingTime, buyingDate ->
|
||||
HoldingEntity(id, bankId, accountId, name, isin, wkn, mapToInt(quantity), currency, mapToAmount(totalBalance), mapToAmount(marketValue), performancePercentage?.toFloat(), mapToAmount(totalCostPrice), mapToAmount(averageCostPrice), mapToInstant(pricingTime), mapToDate(buyingDate))
|
||||
HoldingEntity(id, bankId, accountId, name, isin, wkn, 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> =
|
||||
|
@ -274,7 +366,7 @@ open class SqliteBankingRepository(
|
|||
|
||||
holding.name, holding.isin, holding.wkn,
|
||||
|
||||
mapInt(holding.quantity), holding.currency,
|
||||
holding.quantity, holding.currency,
|
||||
|
||||
mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
|
||||
holding.performancePercentage?.toDouble(),
|
||||
|
@ -291,7 +383,7 @@ open class SqliteBankingRepository(
|
|||
holdings.onEach { holding ->
|
||||
accountTransactionQueries.updateHolding(
|
||||
holding.name, holding.isin, holding.wkn,
|
||||
mapInt(holding.quantity), holding.currency,
|
||||
holding.quantity, holding.currency,
|
||||
|
||||
mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
|
||||
holding.performancePercentage?.toDouble(),
|
||||
|
@ -315,8 +407,8 @@ open class SqliteBankingRepository(
|
|||
|
||||
|
||||
override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
|
||||
accountTransactionQueries.getAllTransactionsAsViewModel { id, bankId, accountId, amount, currency, reference, valueDate, otherPartyName, postingText, userSetDisplayName, userSetOtherPartyName ->
|
||||
AccountTransactionViewModel(id, bankId, accountId, mapToAmount(amount), currency, reference, mapToDate(valueDate), otherPartyName, postingText, userSetDisplayName, userSetOtherPartyName)
|
||||
accountTransactionQueries.getAllTransactionsAsViewModel { id, bankId, accountId, amount, currency, reference, valueDate, otherPartyName, postingText, userSetReference, userSetOtherPartyName ->
|
||||
AccountTransactionViewModel(id, bankId, accountId, mapToAmount(amount), currency, reference, mapToDate(valueDate), otherPartyName, postingText, userSetReference, userSetOtherPartyName)
|
||||
}.executeAsList()
|
||||
|
||||
override fun getAllAccountTransactions(): List<AccountTransactionEntity> {
|
||||
|
@ -379,6 +471,22 @@ open class SqliteBankingRepository(
|
|||
return AccountTransactionEntity(getLastInsertedId(), bankId, accountId, transaction)
|
||||
}
|
||||
|
||||
override suspend fun updateTransaction(transaction: AccountTransactionEntity, userSetOtherPartyName: String?, userSetReference: String?, notes: String?) {
|
||||
accountTransactionQueries.transaction {
|
||||
if (transaction.userSetOtherPartyName != userSetOtherPartyName) {
|
||||
accountTransactionQueries.updateAccountTransactionUserSetOtherPartyName(userSetOtherPartyName, transaction.id)
|
||||
}
|
||||
|
||||
if (transaction.userSetReference != userSetReference) {
|
||||
accountTransactionQueries.updateAccountTransactionUserSetOReference(userSetReference, transaction.id)
|
||||
}
|
||||
|
||||
if (transaction.notes != notes) {
|
||||
accountTransactionQueries.updateAccountTransactionNotes(notes, transaction.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getLastInsertedId(): Long =
|
||||
bankQueries.getLastInsertedId().executeAsOne()
|
||||
|
@ -497,7 +605,16 @@ open class SqliteBankingRepository(
|
|||
private fun <E : Enum<E>> mapEnum(enum: Enum<E>): String = enum.name
|
||||
|
||||
private fun <E : Enum<E>> mapToEnum(enumName: String, values: EnumEntries<E>): E =
|
||||
try {
|
||||
values.first { it.name == enumName }
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Could not map enumName '$enumName' to ${values.first()::class}"}
|
||||
throw e
|
||||
}
|
||||
|
||||
|
||||
private fun <E : Enum<E>> mapToEnum(enumName: String, values: EnumEntries<E>, enumNamesToMigrate: Map<String, String>): E =
|
||||
mapToEnum(enumNamesToMigrate[enumName] ?: enumName, values)
|
||||
|
||||
private fun <E : Enum<E>> mapToEnumNullable(enumName: String, values: EnumEntries<E>): E? {
|
||||
val mapped = values.firstOrNull { it.name == enumName }
|
|
@ -1,4 +1,4 @@
|
|||
package net.codinux.banking.dataaccess.entities
|
||||
package net.codinux.banking.persistence.entities
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.client.model.AccountTransaction
|
||||
|
@ -116,9 +116,4 @@ class AccountTransactionEntity(
|
|||
transaction.isReversal,
|
||||
)
|
||||
|
||||
|
||||
override val identifier: String by lazy {
|
||||
"$bankId ${super.identifier}"
|
||||
}
|
||||
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
package net.codinux.banking.dataaccess.entities
|
||||
package net.codinux.banking.persistence.entities
|
||||
|
||||
import net.codinux.banking.client.model.BankAccess
|
||||
import net.codinux.banking.client.model.BankingGroup
|
||||
import net.codinux.banking.client.model.tan.TanMedium
|
||||
|
||||
class BankAccessEntity(
|
||||
val id: Long,
|
||||
|
@ -33,7 +32,10 @@ class BankAccessEntity(
|
|||
displayIndex: Int = 0,
|
||||
|
||||
iconUrl: String? = null,
|
||||
wrongCredentialsEntered: Boolean = false
|
||||
wrongCredentialsEntered: Boolean = false,
|
||||
|
||||
clientData: Any? = null,
|
||||
serializedClientData: String? = null
|
||||
) : BankAccess(domesticBankCode, loginName, password, bankName, bic, customerName, userId, accounts, selectedTanMethodIdentifier, tanMethods, selectedTanMediumIdentifier, tanMedia, bankingGroup, serverAddress, countryCode) {
|
||||
|
||||
init {
|
||||
|
@ -42,6 +44,9 @@ class BankAccessEntity(
|
|||
|
||||
this.iconUrl = iconUrl
|
||||
this.wrongCredentialsEntered = wrongCredentialsEntered
|
||||
|
||||
this.clientData = clientData
|
||||
this.serializedClientData = serializedClientData
|
||||
}
|
||||
|
||||
|
||||
|
@ -55,4 +60,14 @@ class BankAccessEntity(
|
|||
bank.iconUrl, bank.wrongCredentialsEntered,
|
||||
)
|
||||
|
||||
|
||||
override val accountsSorted: List<BankAccountEntity>
|
||||
get() = accounts.sortedBy { it.displayIndex }
|
||||
|
||||
override val tanMethodsSorted: List<TanMethodEntity>
|
||||
get() = tanMethods.sortedBy { it.identifier }
|
||||
|
||||
override val tanMediaSorted: List<TanMediumEntity>
|
||||
get() = tanMedia.sortedBy { it.status }
|
||||
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package net.codinux.banking.dataaccess.entities
|
||||
package net.codinux.banking.persistence.entities
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
|
@ -1,4 +1,4 @@
|
|||
package net.codinux.banking.dataaccess.entities
|
||||
package net.codinux.banking.persistence.entities
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
@ -15,7 +15,7 @@ class HoldingEntity(
|
|||
isin: String? = null,
|
||||
wkn: String? = null,
|
||||
|
||||
quantity: Int? = null,
|
||||
quantity: Double? = null,
|
||||
currency: String? = null,
|
||||
|
||||
totalBalance: Amount? = null,
|
|
@ -1,4 +1,4 @@
|
|||
package net.codinux.banking.dataaccess.entities
|
||||
package net.codinux.banking.persistence.entities
|
||||
|
||||
import net.codinux.banking.client.model.tan.*
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package net.codinux.banking.dataaccess.entities
|
||||
package net.codinux.banking.persistence.entities
|
||||
|
||||
import net.codinux.banking.client.model.tan.AllowedTanFormat
|
||||
import net.codinux.banking.client.model.tan.TanMethod
|
|
@ -0,0 +1,17 @@
|
|||
package net.codinux.banking.persistence.entities
|
||||
|
||||
import net.codinux.banking.ui.model.settings.TransactionsGrouping
|
||||
|
||||
class UiSettingsEntity(
|
||||
|
||||
val showBalance: Boolean,
|
||||
|
||||
val transactionsGrouping: TransactionsGrouping,
|
||||
|
||||
val showTransactionsInAlternatingColors: Boolean,
|
||||
|
||||
val showBankIcons: Boolean,
|
||||
|
||||
val showColoredAmounts: Boolean
|
||||
|
||||
)
|
|
@ -3,7 +3,7 @@ package net.codinux.banking.ui.model
|
|||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.client.model.AccountTransaction
|
||||
import net.codinux.banking.client.model.Amount
|
||||
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
|
||||
import net.codinux.banking.persistence.entities.AccountTransactionEntity
|
||||
|
||||
data class AccountTransactionViewModel(
|
||||
val id: Long,
|
||||
|
@ -17,8 +17,8 @@ data class AccountTransactionViewModel(
|
|||
val otherPartyName: String? = null,
|
||||
|
||||
val postingText: String? = null,
|
||||
val userSetReference: String? = null,
|
||||
val userSetOtherPartyName: String? = null
|
||||
var userSetReference: String? = null,
|
||||
var userSetOtherPartyName: String? = null
|
||||
) {
|
||||
constructor(entity: AccountTransactionEntity) : this(entity.id, entity.bankId, entity.accountId, entity)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package net.codinux.banking.ui.model.settings
|
||||
|
||||
class ImageSettings(
|
||||
val id: String,
|
||||
|
||||
var height: Int,
|
||||
|
||||
var frequency: Int? = null // only needed for flicker code
|
||||
) {
|
||||
override fun toString() = "$id $height${if (frequency != null) " (frequency = $frequency)" else ""}"
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package net.codinux.banking.ui.model
|
||||
package net.codinux.banking.ui.model.settings
|
||||
|
||||
enum class TransactionsGrouping {
|
||||
None,
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
CREATE TABLE IF NOT EXISTS ImageSettings (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
height INTEGER NOT NULL,
|
||||
width INTEGER, -- not used right now, add it just in case
|
||||
|
||||
frequency INTEGER
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE Holding DROP COLUMN quantity;
|
||||
|
||||
ALTER TABLE Holding ADD COLUMN quantity REAL;
|
|
@ -145,6 +145,31 @@ SELECT AccountTransaction.*
|
|||
FROM AccountTransaction WHERE id = ?;
|
||||
|
||||
|
||||
updateAccountTransactionUserSetOtherPartyName:
|
||||
UPDATE AccountTransaction
|
||||
SET userSetOtherPartyName = ?
|
||||
WHERE id = ?;
|
||||
|
||||
updateAccountTransactionUserSetOReference:
|
||||
UPDATE AccountTransaction
|
||||
SET userSetReference = ?
|
||||
WHERE id = ?;
|
||||
|
||||
updateAccountTransactionNotes:
|
||||
UPDATE AccountTransaction
|
||||
SET notes = ?
|
||||
WHERE id = ?;
|
||||
|
||||
|
||||
deleteTransactionsByBankId {
|
||||
DELETE FROM BankAccount
|
||||
WHERE bankId = :bankId;
|
||||
|
||||
DELETE FROM Holding
|
||||
WHERE bankId = :bankId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Holding (
|
||||
|
@ -157,7 +182,7 @@ CREATE TABLE IF NOT EXISTS Holding (
|
|||
isin TEXT,
|
||||
wkn TEXT,
|
||||
|
||||
quantity INTEGER ,
|
||||
quantity REAL,
|
||||
currency TEXT,
|
||||
|
||||
totalBalance TEXT,
|
|
@ -79,6 +79,44 @@ SELECT BankAccess.*
|
|||
FROM BankAccess;
|
||||
|
||||
|
||||
updateBankLoginName:
|
||||
UPDATE BankAccess
|
||||
SET loginName = ?
|
||||
WHERE id = ?;
|
||||
|
||||
updateBankPassword:
|
||||
UPDATE BankAccess
|
||||
SET password = ?
|
||||
WHERE id = ?;
|
||||
|
||||
updateBankUserSetDisplayName:
|
||||
UPDATE BankAccess
|
||||
SET userSetDisplayName = ?
|
||||
WHERE id = ?;
|
||||
|
||||
|
||||
updateBankClientData:
|
||||
UPDATE BankAccess
|
||||
SET clientData = ?
|
||||
WHERE id = ?;
|
||||
|
||||
|
||||
deleteBank {
|
||||
DELETE FROM TanMethod
|
||||
WHERE bankId = :bankId;
|
||||
|
||||
DELETE FROM TanMedium
|
||||
WHERE bankId = :bankId;
|
||||
|
||||
DELETE FROM BankAccount
|
||||
WHERE bankId = :bankId;
|
||||
|
||||
DELETE FROM BankAccess
|
||||
WHERE id = :bankId;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS BankAccount (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
@ -155,6 +193,28 @@ SELECT BankAccount.*
|
|||
FROM BankAccount;
|
||||
|
||||
|
||||
updateBankAccount:
|
||||
UPDATE BankAccount
|
||||
SET balance = :balance, lastAccountUpdateTime = :lastAccountUpdateTime, retrievedTransactionsFrom = :retrievedTransactionsFrom
|
||||
WHERE id = :accountId;
|
||||
|
||||
|
||||
updateBankAccountUserSetDisplayName:
|
||||
UPDATE BankAccount
|
||||
SET userSetDisplayName = ?
|
||||
WHERE id = ?;
|
||||
|
||||
updateBankAccountHideAccount:
|
||||
UPDATE BankAccount
|
||||
SET hideAccount = ?
|
||||
WHERE id = ?;
|
||||
|
||||
updateBankAccountIncludeInAutomaticAccountsUpdate:
|
||||
UPDATE BankAccount
|
||||
SET includeInAutomaticAccountsUpdate = ?
|
||||
WHERE id = ?;
|
||||
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS TanMethod (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@ -70,3 +70,22 @@ SELECT * FROM UiSettings WHERE id = 1;
|
|||
upsertUiSettings:
|
||||
INSERT OR REPLACE INTO UiSettings(id, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors)
|
||||
VALUES (1, ?, ?, ?, ?, ?);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ImageSettings (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
height INTEGER NOT NULL,
|
||||
width INTEGER, -- not used right now, add it just in case
|
||||
|
||||
frequency INTEGER
|
||||
);
|
||||
|
||||
|
||||
getImageSettings:
|
||||
SELECT height, frequency FROM ImageSettings WHERE id = ?;
|
||||
|
||||
upsertImageSettings:
|
||||
INSERT OR REPLACE INTO ImageSettings(id, height, frequency)
|
||||
VALUES (?, ?, ?);
|
|
@ -0,0 +1,10 @@
|
|||
package net.codinux.banking.persistence
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.synchronous
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlSchema
|
||||
import app.cash.sqldelight.driver.native.NativeSqliteDriver
|
||||
|
||||
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver =
|
||||
NativeSqliteDriver(schema.synchronous(), dbName)
|
|
@ -0,0 +1,9 @@
|
|||
package net.codinux.banking.persistence
|
||||
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlSchema
|
||||
|
||||
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver {
|
||||
throw NotImplementedError("TODO")
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package net.codinux.banking.persistence
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.synchronous
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlSchema
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
|
||||
val dataDirectory: File = determineDataDirectory()
|
||||
|
||||
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver {
|
||||
val dbDir = File(dataDirectory, "db").also { it.mkdirs() }
|
||||
val databaseFile = File(dbDir, dbName)
|
||||
|
||||
return JdbcSqliteDriver("jdbc:sqlite:${databaseFile.path}").also { driver ->
|
||||
schema.synchronous().also { schema ->
|
||||
if (databaseFile.exists() == false) {
|
||||
schema.create(driver)
|
||||
}
|
||||
|
||||
schema.migrate(driver, schema.version, version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun setSchemaVersion(driver: SqlDriver, schemaVersion: Int) {
|
||||
driver.execute(null, "PRAGMA schema_version=$schemaVersion;", 0)
|
||||
}
|
||||
|
||||
private fun determineDataDirectory(): File {
|
||||
val currentDir = Path(System.getProperty("user.dir"))
|
||||
|
||||
// if the current directory is writable, use that one (the default for development)
|
||||
val dataDir = if (Files.isWritable(currentDir)) { // couldn't believe it, but java.io.File returned folder is writable for "C:\\Program Files\\"
|
||||
File(currentDir.absolutePathString(), "data")
|
||||
} else { // otherwise use .bankmeister dir in user's home dir (the default for releases)
|
||||
File(determineOsDependentUserDataDir(), ".bankmeister")
|
||||
}
|
||||
|
||||
return dataDir.also { it.mkdirs() }
|
||||
}
|
||||
|
||||
private fun determineOsDependentUserDataDir(): File {
|
||||
val userHomeString = System.getProperty("user.home")
|
||||
val userHome = File(userHomeString)
|
||||
val windowsLocalAppDataDir = System.getenv("LOCALAPPDATA")?.takeUnless { it.isBlank() }
|
||||
|
||||
return if (windowsLocalAppDataDir != null) {
|
||||
File(windowsLocalAppDataDir)
|
||||
} else if (userHomeString.startsWith("/")) {
|
||||
val osName = System.getProperty("os.name")
|
||||
if (osName.contains("mac", true) || osName.contains("darwin", true)) { // macOS
|
||||
File(userHome, "Library/Application Support")
|
||||
} else if (osName.contains("nux")) { // Linux
|
||||
val localShareDirectory = File(userHome, ".local/share")
|
||||
val configDir = File(userHome, ".config")
|
||||
|
||||
if (localShareDirectory.exists()) {
|
||||
localShareDirectory
|
||||
} else if (configDir.exists()) {
|
||||
configDir
|
||||
} else {
|
||||
userHome
|
||||
}
|
||||
} else { // unknown
|
||||
userHome
|
||||
}
|
||||
} else if (userHomeString.length > 3 && userHomeString[1] == ':' && userHomeString[2] == '\\') { // Windows, but LOCALAPPDATA is not set
|
||||
userHome // File(userHome, "AppData\Local")
|
||||
} else {
|
||||
userHome
|
||||
}
|
||||
}
|
|
@ -1,23 +1,18 @@
|
|||
package net.codinux.banking.dataaccess
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.synchronous
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.client.model.*
|
||||
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
|
||||
import net.codinux.banking.persistence.SqliteBankingRepository
|
||||
import net.codinux.banking.persistence.entities.AccountTransactionEntity
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class SqliteBankingRepositoryTest {
|
||||
|
||||
private val sqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).apply {
|
||||
BankmeisterDb.Schema.synchronous().create(this)
|
||||
}
|
||||
|
||||
private val underTest = object : SqliteBankingRepository(sqlDriver) {
|
||||
override public suspend fun persistTransaction(bankId: Long, accountId: Long, transaction: AccountTransaction): AccountTransactionEntity =
|
||||
private val underTest = object : SqliteBankingRepository() {
|
||||
public override suspend fun persistTransaction(bankId: Long, accountId: Long, transaction: AccountTransaction): AccountTransactionEntity =
|
||||
super.persistTransaction(bankId, accountId, transaction)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.compose.desktop.tasks.AbstractJarsFlattenTask
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
|
||||
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
|
||||
|
||||
plugins {
|
||||
|
@ -11,18 +11,23 @@ plugins {
|
|||
alias(libs.plugins.compose.compiler)
|
||||
|
||||
alias(libs.plugins.kotlinxSerialization)
|
||||
|
||||
alias(libs.plugins.sqldelight)
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
compilerOptions {
|
||||
// suppresses compiler warning: [EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING] 'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta.
|
||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||
}
|
||||
|
||||
|
||||
js {
|
||||
moduleName = "composeApp"
|
||||
moduleName = "Bankmeister"
|
||||
browser {
|
||||
val projectDirPath = project.projectDir.path
|
||||
commonWebpackConfig {
|
||||
outputFileName = "composeApp.js"
|
||||
outputFileName = "Bankmeister.js"
|
||||
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
|
||||
static = (static ?: mutableListOf()).apply {
|
||||
// Serve sources to debug inside browser
|
||||
|
@ -50,26 +55,36 @@ kotlin {
|
|||
iosSimulatorArm64()
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
isStatic = true
|
||||
baseName = "BankmeisterFramework"
|
||||
isStatic = false
|
||||
}
|
||||
|
||||
// don't know why but this has to be added here, adding it in BankingPersistence.build.gradle.kt does not work
|
||||
iosTarget.binaries.forEach { binary ->
|
||||
if (binary is org.jetbrains.kotlin.gradle.plugin.mpp.Framework) {
|
||||
binary.linkerOpts.add("-lsqlite3") // without this we get a lot of "Undefined symbol _co_touchlab_sqliter..." errors in Xcode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultHierarchyTemplate()
|
||||
|
||||
|
||||
sourceSets {
|
||||
val desktopMain by getting
|
||||
|
||||
commonMain.dependencies {
|
||||
implementation(project(":BankingPersistence"))
|
||||
|
||||
implementation(libs.banking.client.model)
|
||||
implementation(libs.fints4k.banking.client)
|
||||
implementation(libs.bank.finder)
|
||||
implementation(libs.epcqrcode)
|
||||
|
||||
implementation(libs.kcsv)
|
||||
implementation(libs.klf)
|
||||
implementation(libs.kotlinx.serializable)
|
||||
|
||||
implementation(libs.sqldelight.runtime)
|
||||
implementation(libs.sqldelight.coroutines.extensions)
|
||||
implementation(libs.sqldelight.paging.extensions)
|
||||
|
||||
// UI
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
|
@ -92,12 +107,20 @@ kotlin {
|
|||
androidMain.dependencies {
|
||||
implementation(compose.preview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.fragment) // to fix bug IllegalArgumentException: Can only use lower 16 bits for requestCode
|
||||
implementation(libs.androidx.biometric)
|
||||
|
||||
implementation(libs.sqldelight.android.driver)
|
||||
implementation(libs.favre.bcrypt)
|
||||
|
||||
// for reading EPC QR Codes from camera
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.camerax.camera2)
|
||||
implementation(libs.camerax.view)
|
||||
implementation(libs.camerax.lifecycle)
|
||||
}
|
||||
|
||||
nativeMain.dependencies {
|
||||
implementation(libs.sqldelight.native.driver)
|
||||
iosMain.dependencies {
|
||||
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
|
@ -108,21 +131,10 @@ kotlin {
|
|||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
implementation(libs.favre.bcrypt)
|
||||
|
||||
implementation(libs.logback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("BankmeisterDb") {
|
||||
packageName.set("net.codinux.banking.dataaccess")
|
||||
generateAsync = true
|
||||
|
||||
schemaOutputDirectory = file("src/commonMain/sqldelight/databases")
|
||||
implementation(libs.janino)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -140,8 +152,8 @@ android {
|
|||
applicationId = "net.codinux.banking.android" // the appId of the old Bankmeister app to be able to use the old PlayStore entry
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 10
|
||||
versionName = "1.0.0-Alpha-12"
|
||||
versionCode = 21
|
||||
versionName = "1.0.0-Alpha-15"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
@ -189,11 +201,34 @@ compose.desktop {
|
|||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "net.codinux.banking.ui"
|
||||
packageVersion = "1.0.0"
|
||||
|
||||
modules("java.sql", "java.naming") // java.naming is required by logback
|
||||
|
||||
packageName = "Bankmeister"
|
||||
packageVersion = "0.9.0" // minor version < 1 (DMG) and dashes as in '1.0.0-Alpha-14' (DMG, MSI, RPM) are not allowed
|
||||
description = "Datenschutzfreundliche Multi-Banking App für die meisten deutschen Banken"
|
||||
copyright = "© 2024 codinux GmbH & Co.KG. All rights reserved."
|
||||
vendor = "codinux GmbH & Co.KG"
|
||||
|
||||
macOS {
|
||||
bundleID = "net.codinux.banking.ui"
|
||||
appCategory = "public.app-category.finance"
|
||||
dmgPackageVersion = "1.0.0"
|
||||
|
||||
iconFile = project.file("../docs/res/AppIcons/distributions/AppIcon.icns")
|
||||
}
|
||||
windows {
|
||||
// a unique ID, which enables users to update an app via installer, when an updated version is newer, than an installed version.
|
||||
// The value must remain constant for a single application. See [the link](https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html) for details on generating a UUID.
|
||||
upgradeUuid = "F62896E2-382E-4311-9683-1AB3AA4EB9E7"
|
||||
|
||||
menu = true
|
||||
|
||||
iconFile = project.file("../docs/res/AppIcons/distributions/AppIcon.ico")
|
||||
}
|
||||
linux {
|
||||
iconFile = project.file("../docs/res/AppIcons/distributions/AppIcon.png")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes.release.proguard {
|
||||
|
@ -202,3 +237,58 @@ compose.desktop {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
gradle.taskGraph.whenReady {
|
||||
tasks.named<AbstractJarsFlattenTask>("flattenJars") {
|
||||
removeThirdPartySignaturesFromJar()
|
||||
}
|
||||
|
||||
tasks.named<AbstractJarsFlattenTask>("flattenReleaseJars") {
|
||||
removeThirdPartySignaturesFromJar()
|
||||
}
|
||||
}
|
||||
|
||||
// Signatures of third party libraries get copied to output jar's META-INF folder so that java -jar refuses to run created uber jar:
|
||||
// Error: A JNI error has occurred, please check your installation and try again
|
||||
// Exception in thread "main" java.lang.SecurityException: Invalid signature file digest for Manifest main attributes
|
||||
// at java.base/sun.security.util.SignatureFileVerifier.processImpl(SignatureFileVerifier.java:340)
|
||||
// at java.base/sun.security.util.SignatureFileVerifier.process(SignatureFileVerifier.java:282)
|
||||
// at java.base/java.util.jar.JarVerifier.processEntry(JarVerifier.java:276)
|
||||
//
|
||||
// -> remove signatures of third party libraries from jar's META-INF folder
|
||||
fun AbstractJarsFlattenTask.removeThirdPartySignaturesFromJar() {
|
||||
val outputJar = (this.flattenedJar as? FileSystemLocationProperty<*>)?.asFile?.get()
|
||||
|
||||
doLast {
|
||||
if (outputJar != null && outputJar.exists()) {
|
||||
val extractedFilesFolder = File(outputJar.parentFile, "extracted").also { it.mkdirs() }
|
||||
extractedFilesFolder.deleteRecursively()
|
||||
|
||||
project.copy { // unzip jar file
|
||||
from(project.zipTree(outputJar))
|
||||
into(extractedFilesFolder)
|
||||
}
|
||||
|
||||
// Remove unwanted META-INF files (*.SF, *.DSA, *.RSA)
|
||||
project.fileTree(extractedFilesFolder.resolve("META-INF")).matching {
|
||||
include("*.SF", "*.DSA", "*.RSA")
|
||||
}.forEach {
|
||||
it.delete() // Delete the matching signature files
|
||||
}
|
||||
|
||||
outputJar.delete() // Remove the original JAR
|
||||
|
||||
// Zip the modified content back into a new JAR using Ant
|
||||
ant.withGroovyBuilder {
|
||||
"zip"(
|
||||
"destfile" to outputJar,
|
||||
"basedir" to extractedFilesFolder
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up the temporary directory
|
||||
extractedFilesFolder.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,6 +2,10 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
|
@ -1,30 +1,55 @@
|
|||
package net.codinux.banking.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import net.codinux.banking.persistence.AndroidContext
|
||||
import net.codinux.banking.ui.service.AuthenticationService
|
||||
import net.codinux.banking.ui.service.BiometricAuthenticationService
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
private val request = ActivityResultContracts.RequestMultiplePermissions()
|
||||
|
||||
private val activityResultLauncher = registerForActivityResult(request) { }
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
ImageService.context = this.applicationContext
|
||||
AndroidContext.applicationContext = this.applicationContext
|
||||
|
||||
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))
|
||||
AuthenticationService.biometricAuthenticationService = BiometricAuthenticationService(this)
|
||||
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun requestPermissions(requiredPermissions: List<String>): Boolean {
|
||||
val requiredPermissionsArray = requiredPermissions.toTypedArray()
|
||||
|
||||
activityResultLauncher.launch(requiredPermissionsArray)
|
||||
|
||||
var result = request.getSynchronousResult(baseContext, requiredPermissionsArray)
|
||||
while (result == null) {
|
||||
result = request.getSynchronousResult(baseContext, requiredPermissionsArray)
|
||||
}
|
||||
|
||||
return if (result.value != null) {
|
||||
val allPermissionsGranted = result.value.entries.filter { it.key in requiredPermissions }.all { it.value == true }
|
||||
allPermissionsGranted
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppAndroidPreview() {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package net.codinux.banking.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
@ -11,6 +13,18 @@ import kotlinx.coroutines.Dispatchers
|
|||
actual val Dispatchers.IOorDefault: CoroutineDispatcher
|
||||
get() = Dispatchers.IO
|
||||
|
||||
actual fun KeyEvent.isBackButtonPressedEvent(): Boolean =
|
||||
this.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_BACK
|
||||
|
||||
|
||||
@Composable
|
||||
actual fun systemPaddings(): PaddingValues = PaddingValues(0.dp)
|
||||
|
||||
actual fun addKeyboardVisibilityListener(onKeyboardVisibilityChanged: (Boolean) -> Unit) {
|
||||
// TODO: may implement, but currently only relevant for iOS
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
actual fun rememberScreenSizeInfo(): ScreenSizeInfo {
|
||||
val config = LocalConfiguration.current
|
||||
|
|
|
@ -10,8 +10,8 @@ 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"))
|
||||
val holding1 = Holding("MUL Amundi MSCI World V", null, null, 1693.0, "EUR", Amount("18578.04"), Amount("16.888"), -0.35f, Amount("17944.48"), Amount("16.828"))
|
||||
val holding2 = Holding("NVIDIA Corp.", null, null, 214.0, "EUR", Amount("21455.36"), Amount("100.18"), 8.8f, Amount("19872.04"), Amount("92.04"))
|
||||
|
||||
RoundedCornersCard {
|
||||
Column {
|
||||
|
|
|
@ -29,6 +29,19 @@ fun EnterTanDialogPreview_TanImage() {
|
|||
|
||||
val bank = BankViewInfo("10010010", "Ihr krasser Login Name", "Phantasie Bank", BankingGroup.Comdirect)
|
||||
|
||||
val tanChallenge = TanChallenge(TanChallengeType.Image, ActionRequiringTan.TransferMoney, "Geben Sie die TAN ein", tanMethod.identifier, listOf(tanMethod), null, emptyList(), tanImage, null, bank)
|
||||
|
||||
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun EnterTanDialogPreview_TanImage_DecodingError() {
|
||||
val tanMethod = TanMethod("photoTAN-Verfahren", TanMethodType.photoTan, "902", 6, AllowedTanFormat.Numeric)
|
||||
val tanImage = TanImage(null, null, "Ja Hoppla, da ist dann wohl etwas schief gelaufen. Hinterfragen Sie Ihre Existenz woran das liegen könnte!")
|
||||
|
||||
val bank = BankViewInfo("10010010", "Ihr krasser Login Name", "Phantasie Bank", BankingGroup.Comdirect)
|
||||
|
||||
val tanChallenge = TanChallenge(TanChallengeType.Image, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethod.identifier, listOf(tanMethod), null, emptyList(), tanImage, null, bank)
|
||||
|
||||
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
|
||||
|
@ -41,7 +54,7 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall
|
|||
val tanImage = TanImage("image/png", tanImageBytes)
|
||||
|
||||
val tanMethods = listOf(
|
||||
TanMethod("chipTAN optisch", TanMethodType.ChipTanFlickercode, "911", 6, AllowedTanFormat.Numeric),
|
||||
TanMethod("chipTAN optisch", TanMethodType.ChipTanFlickerCode, "911", 6, AllowedTanFormat.Numeric),
|
||||
TanMethod("chipTAN-QR", TanMethodType.ChipTanQrCode, "913", 6, AllowedTanFormat.Numeric)
|
||||
)
|
||||
|
||||
|
@ -60,10 +73,20 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall
|
|||
|
||||
@Preview
|
||||
@Composable
|
||||
fun EnterTanDialogPreview_Flickercode() {
|
||||
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickercode, "902"))
|
||||
fun EnterTanDialogPreview_FlickerCode() {
|
||||
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickerCode, "902"))
|
||||
val bank = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank)
|
||||
val tanChallenge = TanChallenge(TanChallengeType.Flickercode, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("", ""))
|
||||
val tanChallenge = TanChallenge(TanChallengeType.FlickerCode, ActionRequiringTan.GetTanMedia, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("100880077104", "0604800771040F"))
|
||||
|
||||
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun EnterTanDialogPreview_FlickerCode_DecodingError() {
|
||||
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickerCode, "902"))
|
||||
val bank = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank)
|
||||
val tanChallenge = TanChallenge(TanChallengeType.FlickerCode, ActionRequiringTan.ChangeTanMedium, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("100880077104", null, decodingError = "Ja Hoppla, da ist dann wohl etwas schief gelaufen."))
|
||||
|
||||
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SegmentedControlPreview() {
|
||||
SegmentedControl(
|
||||
options = listOf("Option 1", "Option 2", "Option 3"),
|
||||
selectedOption = "Option 1",
|
||||
onOptionSelected = { }
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SegmentedControlPreview_OnlyTwoOptions() {
|
||||
SegmentedControl(
|
||||
options = listOf("Option 1", "Option 2"),
|
||||
selectedOption = "Option 2",
|
||||
onOptionSelected = { }
|
||||
)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProtectAppSettingsDialogPreview() {
|
||||
val appSettings = AppSettings(AppAuthenticationMethod.Password)
|
||||
|
||||
ProtectAppSettingsDialog(appSettings) { }
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
|
||||
actual object AuthenticationService {
|
||||
|
||||
internal var biometricAuthenticationService: BiometricAuthenticationService? = null
|
||||
|
||||
|
||||
actual fun hashPassword(password: String): String =
|
||||
BCrypt.withDefaults().hashToString(12, password.toCharArray())
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
|
||||
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
|
||||
|
||||
|
||||
actual val supportsBiometricAuthentication: Boolean
|
||||
get() = biometricAuthenticationService?.supportsBiometricAuthentication ?: false
|
||||
|
||||
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
if (biometricAuthenticationService != null) {
|
||||
biometricAuthenticationService!!.authenticate(null, authenticationResult)
|
||||
} else {
|
||||
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import android.os.Build
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import net.codinux.banking.ui.R
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
import javax.crypto.Cipher
|
||||
|
||||
class BiometricAuthenticationService(
|
||||
private val activity: FragmentActivity
|
||||
) {
|
||||
|
||||
private val biometricManager: BiometricManager = BiometricManager.from(activity)
|
||||
|
||||
private val allowedAuthenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) BIOMETRIC_STRONG
|
||||
else BIOMETRIC_STRONG or BIOMETRIC_WEAK
|
||||
|
||||
|
||||
val supportsBiometricAuthentication: Boolean by lazy {
|
||||
biometricManager.canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
|
||||
fun authenticate(cipher: Cipher?, authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
val executor = ContextCompat.getMainExecutor(this.activity)
|
||||
|
||||
val biometricPrompt = BiometricPrompt(activity, executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errorString)
|
||||
authenticationResult(AuthenticationResult(false, errorString.toString()))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
|
||||
authenticationResult(AuthenticationResult(true))
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
authenticationResult(AuthenticationResult(false))
|
||||
}
|
||||
})
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(this.activity.getString(R.string.activity_login_authenticate_with_biometrics_prompt))
|
||||
//.setSubtitle() // TODO: add subtitle?
|
||||
.setNegativeButtonText(this.activity.getString(android.R.string.cancel)) // is not allowed when device credentials are allowed
|
||||
.setAllowedAuthenticators(allowedAuthenticators)
|
||||
.build()
|
||||
|
||||
if (cipher == null) {
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
} else {
|
||||
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,22 +1,16 @@
|
|||
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.banking.persistence.AndroidContext
|
||||
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 cacheDir by lazy { File(AndroidContext.applicationContext.cacheDir, "imageCache").also { it.mkdirs() } }
|
||||
|
||||
private val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
object PermissionsService {
|
||||
|
||||
fun allPermissionsGranted(baseContext: Context, permissions: List<String>) = permissions.all {
|
||||
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.view.ScaleGestureDetector
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.zxing.*
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import net.codinux.banking.persistence.AndroidContext
|
||||
import net.codinux.banking.ui.MainActivity
|
||||
import net.codinux.log.logger
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
actual object QrCodeService {
|
||||
|
||||
private val RequiredPermissions = listOf(Manifest.permission.CAMERA)
|
||||
|
||||
private val cameraExecutor = Executors.newCachedThreadPool()
|
||||
|
||||
private val log by logger()
|
||||
|
||||
|
||||
actual val supportsReadingQrCodesFromCamera = hasCamera()
|
||||
|
||||
private fun hasCamera(): Boolean = AndroidContext.applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
|
||||
|
||||
|
||||
@Composable
|
||||
actual fun readQrCodeFromCamera(resultCallback: (QrCodeReadResult) -> Unit) {
|
||||
val mainActivity = LocalLifecycleOwner.current as MainActivity // we only have MainActivity, so we can be sure that LocalLifecycleOwner.current is MainActivity
|
||||
|
||||
if (PermissionsService.allPermissionsGranted(AndroidContext.applicationContext, RequiredPermissions) == false &&
|
||||
mainActivity.requestPermissions(RequiredPermissions) == false) {
|
||||
return // we don't have the permission to start the camera
|
||||
}
|
||||
|
||||
val previewView = remember {
|
||||
PreviewView(mainActivity)
|
||||
}
|
||||
|
||||
setupCameraView(previewView, mainActivity, resultCallback)
|
||||
|
||||
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
private fun setupCameraView(previewView: PreviewView, mainActivity: MainActivity, resultCallback: (QrCodeReadResult) -> Unit) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(mainActivity)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
// Used to bind the lifecycle of cameras to the lifecycle owner
|
||||
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
|
||||
|
||||
// Preview
|
||||
val preview = Preview.Builder()
|
||||
.build()
|
||||
.also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
|
||||
// Select back camera as a default
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
val imageAnalyzer = ImageAnalysis.Builder()
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(cameraExecutor, QrCodeImageAnalyzer(resultCallback))
|
||||
}
|
||||
|
||||
try {
|
||||
// Unbind use cases before rebinding
|
||||
cameraProvider.unbindAll()
|
||||
|
||||
// Bind use cases to camera
|
||||
val camera = cameraProvider.bindToLifecycle(mainActivity, cameraSelector, preview, imageAnalyzer)
|
||||
|
||||
configureCameraControl(camera, previewView, mainActivity)
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Use case binding failed" }
|
||||
}
|
||||
|
||||
}, ContextCompat.getMainExecutor(mainActivity))
|
||||
}
|
||||
|
||||
private fun configureCameraControl(
|
||||
camera: Camera,
|
||||
previewView: PreviewView,
|
||||
mainActivity: MainActivity
|
||||
) {
|
||||
// Listen to pinch gestures
|
||||
val listener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
// Get the camera's current zoom ratio
|
||||
val currentZoomRatio = camera.cameraInfo.zoomState.value?.zoomRatio ?: 0F
|
||||
|
||||
// Get the pinch gesture's scaling factor
|
||||
val delta = detector.scaleFactor
|
||||
|
||||
// Update the camera's zoom ratio. This is an asynchronous operation that returns
|
||||
// a ListenableFuture, allowing you to listen to when the operation completes.
|
||||
camera.cameraControl.setZoomRatio(currentZoomRatio * delta)
|
||||
|
||||
return true // Return true, as the event was handled
|
||||
}
|
||||
}
|
||||
val scaleGestureDetector = ScaleGestureDetector(mainActivity, listener)
|
||||
|
||||
previewView.setOnTouchListener { _, event ->
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class QrCodeImageAnalyzer(private val resultCallback: (QrCodeReadResult) -> Unit) : ImageAnalysis.Analyzer {
|
||||
|
||||
private val reader = QRCodeReader()
|
||||
|
||||
private val readerHints = readerHintsForCharset(Charsets.UTF_8.name())
|
||||
|
||||
private val log by logger()
|
||||
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
val bitmap = getBinaryBitmap(image)
|
||||
|
||||
val result = reader.decode(bitmap, readerHints)
|
||||
|
||||
if (result != null && result.text != null) {
|
||||
this.resultCallback(QrCodeReadResult(result.text))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e !is NotFoundException) {
|
||||
log.error(e) { "Could not decode image to QR code" }
|
||||
}
|
||||
}
|
||||
|
||||
image.close() // to continue image analysis / avoid blocking production of further images
|
||||
}
|
||||
|
||||
private fun ByteBuffer.toIntArray(): IntArray {
|
||||
val bytes = this.toByteArray()
|
||||
|
||||
val pixels = IntArray(bytes.size)
|
||||
bytes.indices.forEach { index ->
|
||||
pixels[index] = bytes[index].toInt() and 0xFF
|
||||
}
|
||||
|
||||
return pixels
|
||||
}
|
||||
|
||||
private fun ByteBuffer.toByteArray(): ByteArray {
|
||||
rewind() // Rewind the buffer to zero
|
||||
val data = ByteArray(remaining())
|
||||
get(data) // Copy the buffer into a byte array
|
||||
return data // Return the byte array
|
||||
}
|
||||
|
||||
private fun getBinaryBitmap(image: ImageProxy): BinaryBitmap {
|
||||
val buffer = image.planes[0].buffer
|
||||
val bitmapBuffer = buffer.toIntArray()
|
||||
|
||||
val luminanceSource = RGBLuminanceSource(image.width, image.height, bitmapBuffer)
|
||||
return BinaryBitmap(HybridBinarizer(luminanceSource))
|
||||
}
|
||||
|
||||
private fun readerHintsForCharset(charset: String): Map<DecodeHintType, *> = buildMap {
|
||||
// put(DecodeHintType.TRY_HARDER, true) // optimize for accuracy, not speed
|
||||
put(DecodeHintType.CHARACTER_SET, charset)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
<resources>
|
||||
<string name="app_name">Bankmeister</string>
|
||||
|
||||
<string name="activity_login_authenticate_with_biometrics_prompt">Authentifizieren Sich sich um die App zu entsperren</string>
|
||||
</resources>
|
Binary file not shown.
After Width: | Height: | Size: 5.0 KiB |
|
@ -1,47 +0,0 @@
|
|||
package net.codinux.banking.dataaccess
|
||||
|
||||
import net.codinux.banking.client.model.AccountTransaction
|
||||
import net.codinux.banking.client.model.BankAccess
|
||||
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.BankAccessEntity
|
||||
import net.codinux.banking.ui.model.AccountTransactionViewModel
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
import net.codinux.banking.ui.settings.UiSettings
|
||||
|
||||
interface BankingRepository {
|
||||
|
||||
fun getAppSettings(): AppSettings?
|
||||
|
||||
suspend fun saveAppSettings(settings: AppSettings)
|
||||
|
||||
fun getUiSettings(settings: UiSettings)
|
||||
|
||||
suspend fun saveUiSettings(settings: UiSettings)
|
||||
|
||||
|
||||
fun getAllBanks(): List<BankAccessEntity>
|
||||
|
||||
suspend fun persistBank(bank: BankAccess): BankAccessEntity
|
||||
|
||||
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>
|
||||
|
||||
fun getAllTransactionsForBank(bank: BankAccessEntity): List<AccountTransactionEntity>
|
||||
|
||||
fun getTransactionById(transactionId: Long): AccountTransactionEntity?
|
||||
|
||||
}
|
|
@ -7,9 +7,14 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.persistence.BankingRepository
|
||||
import net.codinux.banking.persistence.SqliteBankingRepository
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
import net.codinux.banking.ui.screens.LoginScreen
|
||||
import net.codinux.banking.ui.screens.MainScreen
|
||||
import net.codinux.log.Log
|
||||
import net.codinux.log.LoggerFactory
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
|
@ -19,20 +24,40 @@ private val typography = Typography(
|
|||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
fun App(repository: BankingRepository? = null) {
|
||||
LoggerFactory.defaultLoggerName = "net.codinux.banking.ui.Bankmeister"
|
||||
|
||||
|
||||
val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, onPrimary = Color.White,
|
||||
secondary = Colors.Accent, secondaryVariant = Colors.Accent, onSecondary = Color.White)
|
||||
|
||||
var isInitialized by remember { mutableStateOf(false) }
|
||||
|
||||
try {
|
||||
if (isInitialized == false) {
|
||||
DI.setRepository(repository ?: SqliteBankingRepository()) // setting repository sets AppSettings, which is required below to determine if user needs to log in
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.error(e) { "Could not set repository" }
|
||||
}
|
||||
|
||||
|
||||
val appSettings = DI.uiState.appSettings.collectAsState().value
|
||||
|
||||
var isLoggedIn by remember(appSettings.authenticationMethod) { mutableStateOf(appSettings.authenticationMethod == AppAuthenticationMethod.None) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
MaterialTheme(colors = colors, typography = typography) {
|
||||
if (isLoggedIn == false) {
|
||||
LoginScreen(appSettings) {
|
||||
isLoggedIn = true
|
||||
}
|
||||
} else {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(isInitialized) {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package net.codinux.banking.ui
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
@ -9,6 +11,15 @@ import kotlinx.coroutines.Dispatchers
|
|||
|
||||
expect val Dispatchers.IOorDefault: CoroutineDispatcher
|
||||
|
||||
expect fun KeyEvent.isBackButtonPressedEvent(): Boolean
|
||||
|
||||
|
||||
@Composable
|
||||
expect fun systemPaddings(): PaddingValues
|
||||
|
||||
expect fun addKeyboardVisibilityListener(onKeyboardVisibilityChanged: (Boolean) -> Unit)
|
||||
|
||||
|
||||
@Composable
|
||||
expect fun rememberScreenSizeInfo(): ScreenSizeInfo
|
||||
|
||||
|
|
|
@ -40,12 +40,17 @@ fun BottomBar(showMenuDrawer: Boolean = true) {
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun toggleDrawerState() {
|
||||
coroutineScope.launch {
|
||||
uiState.drawerState.value.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BottomAppBar {
|
||||
if (showMenuDrawer) {
|
||||
IconButton(
|
||||
onClick = { coroutineScope.launch {
|
||||
uiState.drawerState.value.toggle()
|
||||
} }
|
||||
onClick = { toggleDrawerState() }
|
||||
) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = "Open Navigation Drawer with sidebar menu")
|
||||
}
|
||||
|
@ -61,14 +66,18 @@ fun BottomBar(showMenuDrawer: Boolean = true) {
|
|||
val selectedAccount = transactionsFilter.selectedAccount
|
||||
|
||||
val title = if (selectedAccount == null) {
|
||||
if (banks.isEmpty()) {
|
||||
"Bankmeister"
|
||||
} else {
|
||||
"Alle Konten"
|
||||
}
|
||||
} else if (selectedAccount.bankAccount != null) {
|
||||
selectedAccount.bankAccount.displayName
|
||||
} else {
|
||||
selectedAccount.bank.displayName
|
||||
}
|
||||
|
||||
Text(title, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(title, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clickable { toggleDrawerState() })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,19 +1,26 @@
|
|||
package net.codinux.banking.ui.appskeleton
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusTarget
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import net.codinux.banking.ui.composables.CloseButton
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.config.Internationalization
|
||||
import net.codinux.banking.ui.forms.RoundedCornersCard
|
||||
import net.codinux.banking.ui.forms.Select
|
||||
import net.codinux.banking.ui.model.TransactionsGrouping
|
||||
import net.codinux.banking.ui.isBackButtonPressedEvent
|
||||
import net.codinux.banking.ui.model.settings.TransactionsGrouping
|
||||
|
||||
private val uiState = DI.uiState
|
||||
|
||||
|
@ -37,14 +44,30 @@ fun FilterBar() {
|
|||
|
||||
val months = listOf("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" /*, "1. Quartal", "2. Quartal", "3. Quartal", "4. Quartal" */, null)
|
||||
|
||||
val filterBarFocus = remember { FocusRequester() }
|
||||
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = Modifier.fillMaxSize().zIndex(100f)
|
||||
.padding(bottom = 64.dp, end = 74.dp)
|
||||
.padding(bottom = 64.dp, end = 74.dp).focusable(true).focusRequester(filterBarFocus).focusTarget().onKeyEvent { event ->
|
||||
if (event.isBackButtonPressedEvent() || event.key == Key.Escape) {
|
||||
DI.uiState.showFilterBar.value = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(Modifier.height(230.dp).width(390.dp)) {
|
||||
Column(Modifier.height(190.dp).width(390.dp)) {
|
||||
RoundedCornersCard(cornerSize = 4.dp, shadowElevation = 24.dp) {
|
||||
Column(Modifier.fillMaxWidth().background(Color.White).padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
|
||||
CloseButton("Filterbar schließen", size = 24.dp) {
|
||||
uiState.showFilterBar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Umsätze", Modifier.width(labelsWidth))
|
||||
|
||||
|
@ -81,13 +104,14 @@ fun FilterBar() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.padding(top = 10.dp)) {
|
||||
Text("Zum Schließen bitte wieder auf das Filter Icon klicken, Zurück Button etc. funtioniert nicht (herzlichen Undank UI Framework!)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(filterBarFocus) {
|
||||
filterBarFocus.requestFocus() // focus filter bar so that it receives key events to handle e.g. Escape button press
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package net.codinux.banking.ui.appskeleton
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.config.Style.FabMenuSpacing
|
||||
import net.codinux.banking.ui.config.Style.FabSize
|
||||
import net.codinux.banking.ui.config.Style.SmallFabSize
|
||||
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
|
||||
import net.codinux.banking.ui.service.QrCodeService
|
||||
|
||||
|
||||
private val uiState = DI.uiState
|
||||
|
||||
@Composable
|
||||
fun FloatingActionMenu(
|
||||
showFloatingActionMenu: Boolean,
|
||||
menuItemClicked: () -> Unit
|
||||
) {
|
||||
|
||||
val fabVisibilityAnimation = animateFloatAsState(targetValue = if (showFloatingActionMenu) 1f else 0f)
|
||||
|
||||
val bottomPadding = FabSize + FabSize / 2
|
||||
|
||||
val accountsThatSupportMoneyTransfer = uiState.accountsThatSupportMoneyTransfer.collectAsState().value
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun handleClick(action: () -> Unit) {
|
||||
menuItemClicked()
|
||||
|
||||
coroutineScope.launch {
|
||||
delay(50)
|
||||
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fabVisibilityAnimation.value > 0) {
|
||||
Box(Modifier.fillMaxSize().padding(bottom = bottomPadding, end = 12.dp), contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier, horizontalAlignment = Alignment.End) {
|
||||
FloatingActionMenuItem("Überweisungs-QR-Code erstellen", "EPC QR Code erstellen") {
|
||||
handleClick {
|
||||
uiState.showCreateEpcQrCodeScreen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
if (QrCodeService.supportsReadingQrCodesFromCamera) {
|
||||
FloatingActionMenuItem("Überweisungs-QR-Code lesen", "Neue Überweisung mit Daten aus EPC QR Code (GiroCode, scan2Code, Zahlen mit Code, ...)", enabled = accountsThatSupportMoneyTransfer.isNotEmpty()) {
|
||||
handleClick {
|
||||
uiState.showTransferMoneyFromEpcQrCodeScreen.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionMenuItem("Überweisung", "Neue Überweisung", enabled = accountsThatSupportMoneyTransfer.isNotEmpty()) {
|
||||
handleClick {
|
||||
uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData()
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionMenuItem("Konto", "Neues Konto hinzufügen") {
|
||||
handleClick {
|
||||
uiState.showAddAccountDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatingActionMenuItem(
|
||||
label: String,
|
||||
contentDescription: String,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = FabMenuSpacing).clickable(enabled) { onClick() }) {
|
||||
Text(label, fontSize = 16.sp, color = contentColorFor(MaterialTheme.colors.secondary).copy(if (enabled) 1f else ContentAlpha.disabled), modifier = Modifier.padding(end = 8.dp).background(MaterialTheme.colors.secondary).padding(horizontal = 20.dp, vertical = 4.dp)) // the same background color as the FAB
|
||||
|
||||
FloatingActionButton(
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.padding(end = (FabSize - SmallFabSize) / 2).size(SmallFabSize),
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = contentDescription, tint = LocalContentColor.current.copy(if (enabled) LocalContentAlpha.current else ContentAlpha.disabled))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,8 +5,10 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.SaveAs
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
@ -23,7 +25,7 @@ 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
|
||||
import net.codinux.banking.ui.extensions.rememberVerticalScroll
|
||||
import org.jetbrains.compose.resources.imageResource
|
||||
|
||||
private val uiState = DI.uiState
|
||||
|
@ -54,11 +56,11 @@ private val VerticalSpacing = 8.dp
|
|||
fun SideMenuContent() {
|
||||
val drawerState = uiState.drawerState.collectAsState().value
|
||||
|
||||
val accounts = uiState.banks.collectAsState().value
|
||||
val accounts = uiState.accounts.collectAsState().value
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(Modifier.fillMaxSize().background(Colors.DrawerContentBackground).verticalScroll(ScrollState(0), enabled = true)) {
|
||||
Column(Modifier.fillMaxSize().background(Colors.DrawerContentBackground).rememberVerticalScroll()) {
|
||||
Column(Modifier.fillMaxWidth().height(HeaderHeight.dp).background(HeaderBackground).padding(16.dp)) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
|
@ -66,12 +68,12 @@ fun SideMenuContent() {
|
|||
|
||||
Text("Bankmeister", color = Color.White, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp))
|
||||
|
||||
Text("Version 1.0.0 Alpha 12", color = Color.LightGray)
|
||||
Text("Version 1.0.0 Alpha 15", color = Color.LightGray)
|
||||
}
|
||||
|
||||
ItemDivider(color = Colors.DrawerDivider)
|
||||
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) {
|
||||
Column(Modifier.padding(vertical = 24.dp).padding(start = 16.dp, end = 4.dp)) {
|
||||
Column(Modifier.height(ItemHeight), verticalArrangement = Arrangement.Center) {
|
||||
Text("Konten", color = textColor)
|
||||
}
|
||||
|
@ -94,19 +96,6 @@ fun SideMenuContent() {
|
|||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.isNotEmpty()) {
|
||||
Spacer(Modifier.height(VerticalSpacing))
|
||||
|
||||
NavigationMenuItem(itemModifier, "Neue Überweisung", textColor, horizontalPadding = ItemHorizontalPadding,
|
||||
icon = { Icon(Icons.Filled.Add, "Neue Überweisung", Modifier.size(iconSize), tint = textColor) }) {
|
||||
uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData()
|
||||
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.isNotEmpty()) {
|
||||
|
@ -123,6 +112,24 @@ fun SideMenuContent() {
|
|||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationMenuItem(itemModifier, "Appzugang schützen", textColor, horizontalPadding = ItemHorizontalPadding,
|
||||
icon = { Icon(Icons.Outlined.Key, "Appzugang durch Passwort oder Biometrieeingabe schützen", Modifier.size(iconSize), tint = textColor) }) {
|
||||
uiState.showProtectAppSettingsScreen.value = true
|
||||
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationMenuItem(itemModifier, "Feedback", textColor, horizontalPadding = ItemHorizontalPadding,
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Message, "Feedback an die Entwickler geben", Modifier.size(iconSize), tint = textColor) }) {
|
||||
uiState.showFeedbackScreen.value = true
|
||||
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
|
|||
import net.codinux.banking.client.model.BankAccess
|
||||
import net.codinux.banking.client.model.BankViewInfo
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.model.BankInfo
|
||||
import net.codinux.banking.bankfinder.BankInfo
|
||||
import net.dankito.banking.banklistcreator.prettifier.BankingGroupMapper
|
||||
|
||||
private val bankIconService = DI.bankIconService
|
||||
|
@ -31,7 +31,7 @@ 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, bank.bic, 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)
|
||||
}
|
||||
|
|
|
@ -10,8 +10,8 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.dataaccess.entities.BankAccountEntity
|
||||
import net.codinux.banking.dataaccess.entities.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
import net.codinux.banking.ui.config.DI
|
||||
|
||||
private val uiState = DI.uiState
|
||||
|
@ -44,7 +44,7 @@ fun BanksList(
|
|||
accountSelected?.invoke(bank, null)
|
||||
}
|
||||
|
||||
bank.accounts.sortedBy { it.displayIndex }.forEach { account ->
|
||||
bank.accountsSorted.filterNot { it.hideAccount }.forEach { account ->
|
||||
NavigationMenuItem(itemModifier, account.displayName, textColor, iconSize, IconTextSpacing, itemHorizontalPadding, bankAccount = account) {
|
||||
accountSelected?.invoke(bank, account)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package net.codinux.banking.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.config.Style
|
||||
|
||||
@Composable
|
||||
fun CloseButton(contentDescription: String = "Dialog schließen", color: Color = Style.ListItemHeaderTextColor, size: Dp = 32.dp, onClick: () -> Unit) {
|
||||
TextButton(onClick, colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent), contentPadding = PaddingValues(0.dp), modifier = Modifier.size(size)) {
|
||||
Icon(Icons.Filled.Close, contentDescription = contentDescription, Modifier.size(size), tint = color)
|
||||
}
|
||||
}
|
|
@ -2,13 +2,13 @@ package net.codinux.banking.ui.composables
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
@ -19,8 +19,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.dataaccess.entities.BankAccountEntity
|
||||
import net.codinux.banking.dataaccess.entities.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
|
||||
|
@ -94,6 +94,8 @@ fun NavigationMenuItem(
|
|||
bankAccount.balance
|
||||
} else if (bank != null) {
|
||||
calculator.calculateBalanceOfBankAccess(bank)
|
||||
} else if (text == "Alle Konten") {
|
||||
calculator.calculateBalanceOfAllAccounts(DI.uiState.accounts.value)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -101,9 +103,20 @@ fun NavigationMenuItem(
|
|||
if (balance != null) {
|
||||
Text(
|
||||
formatUtil.formatAmount(balance, calculator.getTransactionsCurrency(emptyList())),
|
||||
color = formatUtil.getColorForAmount(balance, showColoredAmounts),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
color = if (showColoredAmounts) formatUtil.getColorForAmount(balance, showColoredAmounts) else textColor,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (bank != null) {
|
||||
if (bankAccount == null) {
|
||||
Column(Modifier.clickable { DI.uiState.showBankSettingsScreenForBank.value = bank }.padding(start = 8.dp).size(24.dp)) {
|
||||
Icon(Icons.Outlined.Settings, "Zu Kontoeinstellungen wechseln", tint = textColor, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bankAccount != null || bank == null) { // show a place holder to match Settings icon's width
|
||||
Spacer(Modifier.padding(start = 8.dp).size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ import androidx.compose.runtime.*
|
|||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.dialogs.*
|
||||
import net.codinux.banking.ui.screens.ExportScreen
|
||||
import net.codinux.banking.ui.screens.*
|
||||
import net.codinux.banking.ui.state.UiState
|
||||
|
||||
private val formatUtil = DI.formatUtil
|
||||
|
@ -15,7 +15,16 @@ private val formatUtil = DI.formatUtil
|
|||
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
|
||||
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
|
||||
val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.collectAsState()
|
||||
val showTransferMoneyFromEpcQrCodeScreen by uiState.showTransferMoneyFromEpcQrCodeScreen.collectAsState()
|
||||
val showCreateEpcQrCodeScreen by uiState.showCreateEpcQrCodeScreen.collectAsState()
|
||||
|
||||
val showAccountTransactionDetailsScreenForId by uiState.showAccountTransactionDetailsScreenForId.collectAsState()
|
||||
val showBankSettingsScreenForBank by uiState.showBankSettingsScreenForBank.collectAsState()
|
||||
val showBankAccountSettingsScreenForAccount by uiState.showBankAccountSettingsScreenForAccount.collectAsState()
|
||||
|
||||
val showExportScreen by uiState.showExportScreen.collectAsState()
|
||||
val showFeedbackScreen by uiState.showFeedbackScreen.collectAsState()
|
||||
val showProtectAppSettingsScreen by uiState.showProtectAppSettingsScreen.collectAsState()
|
||||
|
||||
val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState()
|
||||
val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState()
|
||||
|
@ -32,10 +41,42 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
|
|||
TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null }
|
||||
}
|
||||
|
||||
if (showTransferMoneyFromEpcQrCodeScreen) {
|
||||
TransferMoneyFromQrCodeScreen { uiState.showTransferMoneyFromEpcQrCodeScreen.value = false }
|
||||
}
|
||||
|
||||
if (showCreateEpcQrCodeScreen) {
|
||||
CreateEpcQrCodeScreen { uiState.showCreateEpcQrCodeScreen.value = false }
|
||||
}
|
||||
|
||||
|
||||
showAccountTransactionDetailsScreenForId?.let { transactionId ->
|
||||
DI.bankingService.getTransaction(transactionId)?.let { transaction ->
|
||||
AccountTransactionDetailsScreen(transaction) { uiState.showAccountTransactionDetailsScreenForId.value = null }
|
||||
}
|
||||
}
|
||||
|
||||
showBankSettingsScreenForBank?.let { bank ->
|
||||
BankSettingsScreen(bank) { uiState.showBankSettingsScreenForBank.value = null }
|
||||
}
|
||||
|
||||
showBankAccountSettingsScreenForAccount?.let { account ->
|
||||
BankAccountSettingsScreen(account) { uiState.showBankAccountSettingsScreenForAccount.value = null }
|
||||
}
|
||||
|
||||
|
||||
if (showExportScreen) {
|
||||
ExportScreen { uiState.showExportScreen.value = false }
|
||||
}
|
||||
|
||||
if (showFeedbackScreen) {
|
||||
FeedbackScreen { uiState.showFeedbackScreen.value = false }
|
||||
}
|
||||
|
||||
if (showProtectAppSettingsScreen) {
|
||||
ProtectAppSettingsDialog(uiState.appSettings.value) { uiState.showProtectAppSettingsScreen.value = false }
|
||||
}
|
||||
|
||||
|
||||
tanChallengeReceived?.let { tanChallengeReceived ->
|
||||
EnterTanDialog(tanChallengeReceived) {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package net.codinux.banking.ui.composables.authentification
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Fingerprint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
import net.codinux.banking.ui.service.AuthenticationService
|
||||
import net.codinux.banking.ui.service.safelyAuthenticateWithBiometrics
|
||||
|
||||
@Composable
|
||||
fun BiometricAuthenticationButton(authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.Center) {
|
||||
Button({ AuthenticationService.safelyAuthenticateWithBiometrics(authenticationResult) }, enabled = AuthenticationService.supportsBiometricAuthentication) {
|
||||
Icon(Icons.Outlined.Fingerprint, "Sich mittels Biometrie authentifizieren", Modifier.size(84.dp))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -13,7 +13,7 @@ import net.codinux.banking.ui.config.DI
|
|||
import net.codinux.banking.ui.config.Internationalization
|
||||
import net.codinux.banking.ui.forms.BooleanOption
|
||||
import net.codinux.banking.ui.forms.Select
|
||||
import net.codinux.banking.ui.model.TransactionsGrouping
|
||||
import net.codinux.banking.ui.model.settings.TransactionsGrouping
|
||||
|
||||
@Composable
|
||||
fun UiSettings(modifier: Modifier, textColor: Color = Color.Unspecified) {
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
package net.codinux.banking.ui.composables.tan
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.service.tan.Bit
|
||||
|
||||
@Composable
|
||||
fun ChipTanFlickerCodeStripeView(stripe: Bit, width: Dp, showTanGeneratorMarker: Boolean = false) {
|
||||
Column(Modifier.width(width).fillMaxHeight()) {
|
||||
val markerHeight = width * 0.5f
|
||||
val triangleSize = markerHeight.value * LocalDensity.current.density
|
||||
|
||||
Column(Modifier.padding(bottom = 4.dp).width(width).height(markerHeight), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom) {
|
||||
if (showTanGeneratorMarker) {
|
||||
val path = Path().apply {
|
||||
// Line to the top-right corner of the triangle
|
||||
lineTo(triangleSize, 0f)
|
||||
|
||||
// Line to the bottom-center point of the triangle
|
||||
lineTo(triangleSize / 2, triangleSize)
|
||||
|
||||
// Close the path (line back to the starting point)
|
||||
close()
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.size(markerHeight)) {
|
||||
drawPath(path, Color.White, 1f, Fill)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().background(if (stripe.isHigh) Color.White else Color.Black)) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,193 @@
|
|||
package net.codinux.banking.ui.composables.tan
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
|
||||
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.client.model.tan.FlickerCode
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.model.Config.NewLine
|
||||
import net.codinux.banking.ui.service.tan.Bit
|
||||
import net.codinux.banking.ui.service.tan.FlickerCodeAnimator
|
||||
import net.codinux.banking.ui.service.tan.Step
|
||||
|
||||
|
||||
private const val FrequencyStepSize = 2
|
||||
|
||||
private val DefaultStripesHeight = 240.dp
|
||||
private val StripesHeightStepSize = 7.dp
|
||||
private val StripesWidthStepSize = 2.dp
|
||||
private val SpaceBetweenStripesStepSize = 1.dp
|
||||
|
||||
private val bankingService = DI.bankingService
|
||||
|
||||
|
||||
@Composable
|
||||
fun ChipTanFlickerCodeView(flickerCode: FlickerCode, textColor: Color = Color.Black) {
|
||||
|
||||
val animator = remember { FlickerCodeAnimator() }
|
||||
|
||||
val flickerCodeSettings by remember { mutableStateOf(bankingService.getImageSettingsOrCreateDefault("FlickerCode", 240)) }
|
||||
|
||||
val sizeFactor by remember { mutableStateOf(
|
||||
if (flickerCodeSettings.height == 240) {
|
||||
1
|
||||
} else {
|
||||
val diff = flickerCodeSettings.height.dp - DefaultStripesHeight
|
||||
|
||||
(diff / StripesHeightStepSize).toInt()
|
||||
}
|
||||
) }
|
||||
|
||||
var stripesHeight by remember { mutableStateOf(DefaultStripesHeight + StripesHeightStepSize.times(sizeFactor)) }
|
||||
|
||||
var stripesWidth by remember { mutableStateOf(45.dp + StripesWidthStepSize.times(sizeFactor)) }
|
||||
|
||||
var spaceBetweenStripes by remember { mutableStateOf(15.dp + SpaceBetweenStripesStepSize.times(sizeFactor)) }
|
||||
|
||||
var frequency by remember { mutableStateOf(flickerCodeSettings.frequency ?: FlickerCodeAnimator.DefaultFrequency) }
|
||||
|
||||
var isPaused by remember { mutableStateOf(false) }
|
||||
|
||||
var step by remember { mutableStateOf(Step(Bit.High, Bit.High, Bit.High, Bit.High, Bit.High)) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun setSize(width: Dp, height: Dp, spaceBetween: Dp) {
|
||||
stripesWidth = width
|
||||
stripesHeight = height
|
||||
spaceBetweenStripes = spaceBetween
|
||||
|
||||
flickerCodeSettings.height = height.value.toInt()
|
||||
bankingService.saveImageSettingsDebounced(flickerCodeSettings, coroutineScope)
|
||||
}
|
||||
|
||||
fun decreaseSize() {
|
||||
if (spaceBetweenStripes - SpaceBetweenStripesStepSize > 0.dp) {
|
||||
setSize(
|
||||
stripesWidth - StripesWidthStepSize,
|
||||
stripesHeight - StripesHeightStepSize,
|
||||
spaceBetweenStripes - SpaceBetweenStripesStepSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseSize() { // set also an upper limit to size?
|
||||
setSize(
|
||||
stripesWidth + StripesWidthStepSize,
|
||||
stripesHeight + StripesHeightStepSize,
|
||||
spaceBetweenStripes + SpaceBetweenStripesStepSize
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun setFrequency(newFrequency: Int) {
|
||||
frequency = newFrequency
|
||||
|
||||
animator.setFrequency(newFrequency)
|
||||
|
||||
flickerCodeSettings.frequency = newFrequency
|
||||
bankingService.saveImageSettingsDebounced(flickerCodeSettings, coroutineScope)
|
||||
}
|
||||
|
||||
fun decreaseFrequency() {
|
||||
if (frequency - FrequencyStepSize >= FlickerCodeAnimator.MinFrequency) {
|
||||
setFrequency(frequency - FrequencyStepSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseFrequency() {
|
||||
if (frequency + FrequencyStepSize <= FlickerCodeAnimator.MaxFrequency) {
|
||||
setFrequency(frequency + FrequencyStepSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleIsPaused() {
|
||||
isPaused = !isPaused
|
||||
|
||||
if (isPaused) {
|
||||
animator.pause()
|
||||
} else {
|
||||
animator.resume()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
flickerCode.decodingError?.let {
|
||||
Text("Hier sollte eigentlich ein FlickerCode stehen, dieser konnte jedoch nicht dekodiert werden:${NewLine}${flickerCode.decodingError}", color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
flickerCode.parsedDataSet?.let {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
ImageSizeControls(true, true, textColor, { decreaseSize() }, { increaseSize() })
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Text("Geschw.", color = textColor)
|
||||
|
||||
IconButton({ decreaseFrequency() }, enabled = frequency - FrequencyStepSize > 0) {
|
||||
Icon(Icons.AutoMirrored.Filled.DirectionsWalk, contentDescription = "Frequenz verkleinern", Modifier.size(28.dp), tint = textColor)
|
||||
}
|
||||
|
||||
IconButton({ increaseFrequency() }, enabled = frequency - FrequencyStepSize > 0) {
|
||||
Icon(Icons.AutoMirrored.Filled.DirectionsRun, contentDescription = "Frequenz vergrößern", Modifier.size(28.dp), tint = textColor)
|
||||
}
|
||||
|
||||
IconButton({ toggleIsPaused() }) {
|
||||
if (isPaused) {
|
||||
Icon(Icons.Filled.PlayArrow, "FlickerCode Animation wieder starten", Modifier.size(28.dp), tint = textColor)
|
||||
} else {
|
||||
Icon(Icons.Filled.Pause, "FlickerCode Animation pausieren", Modifier.size(28.dp), tint = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.background(Color.Black).padding(vertical = 20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.fillMaxWidth().height(stripesHeight).background(Color.Black), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
ChipTanFlickerCodeStripeView(step.bit1, stripesWidth, true)
|
||||
|
||||
Spacer(Modifier.width(spaceBetweenStripes))
|
||||
|
||||
ChipTanFlickerCodeStripeView(step.bit2, stripesWidth)
|
||||
|
||||
Spacer(Modifier.width(spaceBetweenStripes))
|
||||
|
||||
ChipTanFlickerCodeStripeView(step.bit3, stripesWidth)
|
||||
|
||||
Spacer(Modifier.width(spaceBetweenStripes))
|
||||
|
||||
ChipTanFlickerCodeStripeView(step.bit4, stripesWidth)
|
||||
|
||||
Spacer(Modifier.width(spaceBetweenStripes))
|
||||
|
||||
ChipTanFlickerCodeStripeView(step.bit5, stripesWidth, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DisposableEffect(animator) {
|
||||
animator.setFrequency(frequency)
|
||||
|
||||
animator.animateFlickerCode(flickerCode, coroutineScope) {
|
||||
step = it
|
||||
}
|
||||
|
||||
onDispose {
|
||||
animator.stop()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package net.codinux.banking.ui.composables.tan
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ZoomIn
|
||||
import androidx.compose.material.icons.filled.ZoomOut
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ImageSizeControls(decreaseEnabled: Boolean, increaseEnabled: Boolean, textColor: Color = Color.Black, onDecreaseImageSize: () -> Unit, onIncreaseImageSize: () -> Unit) {
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Größe", color = textColor, modifier = Modifier.padding(end = 6.dp))
|
||||
|
||||
TextButton({ onDecreaseImageSize() }, enabled = decreaseEnabled, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
|
||||
Icon(Icons.Filled.ZoomOut, contentDescription = "Bild verkleinern", Modifier.size(28.dp), tint = textColor)
|
||||
}
|
||||
|
||||
TextButton({ onIncreaseImageSize() }, enabled = increaseEnabled, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
|
||||
Icon(Icons.Filled.ZoomIn, contentDescription = "Bild vergrößern", Modifier.size(28.dp), tint = textColor)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package net.codinux.banking.ui.composables.tan
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.service.createImageBitmap
|
||||
|
||||
|
||||
private val bankingService = DI.bankingService
|
||||
|
||||
@Composable
|
||||
fun ImageView(
|
||||
imageBytes: ByteArray,
|
||||
imageSettingsId: String,
|
||||
contentDescription: String,
|
||||
initialImageHeight: Int = 300,
|
||||
minImageHeight: Int = 0,
|
||||
maxImageHeight: Int? = null,
|
||||
changeImageSizeStep: Int = 25,
|
||||
textColor: Color = Colors.MaterialThemeTextColor,
|
||||
) {
|
||||
|
||||
val imageSettings = bankingService.getImageSettingsOrCreateDefault(imageSettingsId, initialImageHeight)
|
||||
|
||||
var imageHeight by remember { mutableStateOf(imageSettings.height) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun changeImageSize(by: Int) {
|
||||
imageHeight += by
|
||||
|
||||
imageSettings.height = imageHeight
|
||||
|
||||
bankingService.saveImageSettingsDebounced(imageSettings, coroutineScope)
|
||||
}
|
||||
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
ImageSizeControls(imageHeight > minImageHeight, maxImageHeight == null || imageHeight < maxImageHeight, textColor, { changeImageSize(-changeImageSizeStep) }) { changeImageSize(changeImageSizeStep) }
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(createImageBitmap(imageBytes), contentDescription, Modifier.height(imageHeight.dp), contentScale = ContentScale.FillHeight)
|
||||
}
|
||||
|
||||
}
|
|
@ -3,14 +3,15 @@ package net.codinux.banking.ui.composables.text
|
|||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import net.codinux.banking.ui.config.Style
|
||||
|
||||
@Composable
|
||||
fun HeaderText(title: String, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start) {
|
||||
fun HeaderText(title: String, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start, textColor: Color = Style.HeaderTextColor) {
|
||||
Text(
|
||||
title,
|
||||
color = Style.HeaderTextColor,
|
||||
color = textColor,
|
||||
fontSize = Style.HeaderFontSize,
|
||||
fontWeight = Style.HeaderFontWeight,
|
||||
modifier = modifier,
|
||||
|
|
|
@ -13,14 +13,14 @@ 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.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.HoldingEntity
|
||||
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.forms.RoundedCornersCard
|
||||
import net.codinux.banking.ui.model.AccountTransactionViewModel
|
||||
import net.codinux.banking.ui.model.TransactionsGrouping
|
||||
import net.codinux.banking.ui.model.settings.TransactionsGrouping
|
||||
import net.codinux.banking.ui.service.TransactionsGroupingService
|
||||
|
||||
private val calculator = DI.calculator
|
||||
|
@ -31,7 +31,7 @@ private val formatUtil = DI.formatUtil
|
|||
fun GroupedTransactionsListItems(
|
||||
modifier: Modifier,
|
||||
transactionsToDisplay: List<AccountTransactionViewModel>,
|
||||
holdingsToDisplay: List<Holding>,
|
||||
holdingsToDisplay: List<HoldingEntity>,
|
||||
banksById: Map<Long, BankAccessEntity>,
|
||||
transactionsGrouping: TransactionsGrouping
|
||||
) {
|
||||
|
@ -65,9 +65,9 @@ fun GroupedTransactionsListItems(
|
|||
RoundedCornersCard {
|
||||
Column(Modifier.background(Color.White)) {
|
||||
holdingsToDisplay.forEachIndexed { index, holding ->
|
||||
// key(statementOfHoldings.id) {
|
||||
key(holding.id) {
|
||||
HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1)
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -76,6 +76,7 @@ fun GroupedTransactionsListItems(
|
|||
}
|
||||
|
||||
items(groupedByDate.keys.sortedDescending()) { groupingDate ->
|
||||
key(groupingDate.toEpochDays()) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = DI.formatUtil.formatGroupingDate(groupingDate, transactionsGrouping),
|
||||
|
@ -124,3 +125,4 @@ fun GroupedTransactionsListItems(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -70,7 +70,7 @@ fun HoldingListItem(holding: Holding, isOddItem: Boolean = false, isNotLastItem:
|
|||
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, ")
|
||||
Text(formatUtil.formatQuantity(holding.quantity) + " Stück, ")
|
||||
}
|
||||
|
||||
if (holding.averageCostPrice != null) {
|
||||
|
|
|
@ -51,11 +51,11 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
|
|||
|
||||
DI.uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData(
|
||||
DI.uiState.banks.value.firstNotNullOf { it.accounts.firstOrNull { it.id == transaction.accountId } },
|
||||
transaction.otherPartyName,
|
||||
transaction.otherPartyName, // we don't use userSetOtherPartyName here on purpose
|
||||
transactionEntity?.otherPartyBankId,
|
||||
transactionEntity?.otherPartyAccountId,
|
||||
if (withSameData) transaction.amount else null,
|
||||
if (withSameData) transaction.reference else null
|
||||
if (withSameData) transaction.reference else null // we don't use userSetReference here on purpose
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -66,6 +66,7 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
|
|||
.background(color = backgroundColor)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { DI.uiState.showAccountTransactionDetailsScreenForId.value = transaction.id },
|
||||
onLongPress = {
|
||||
if (transaction.otherPartyName != null) { // TODO: also check if IBAN is set
|
||||
showMenuAt = DpOffset(it.x.dp, it.y.dp - bottomPadding)
|
||||
|
@ -82,7 +83,7 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
|
|||
}
|
||||
|
||||
Text(
|
||||
text = transaction.otherPartyName ?: transaction.postingText ?: "",
|
||||
text = transaction.userSetOtherPartyName ?: transaction.otherPartyName ?: transaction.postingText ?: "",
|
||||
Modifier.fillMaxWidth(),
|
||||
color = Style.ListItemHeaderTextColor,
|
||||
fontWeight = Style.ListItemHeaderWeight,
|
||||
|
@ -94,7 +95,7 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
|
|||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = transaction.reference ?: "",
|
||||
text = transaction.userSetReference ?: transaction.reference ?: "",
|
||||
Modifier.fillMaxWidth(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
|
@ -120,7 +121,7 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
|
|||
offset = showMenuAt ?: DpOffset.Zero,
|
||||
) {
|
||||
DropdownMenuItem({ newMoneyTransferToOtherParty(false) }) {
|
||||
Text("Neue Überweisung an ${transaction.otherPartyName} ...")
|
||||
Text("Neue Überweisung an ${transaction.userSetOtherPartyName ?: transaction.otherPartyName} ...") // really use userSetOtherPartyName here as we don't use it in ShowTransferMoneyDialogData
|
||||
}
|
||||
|
||||
DropdownMenuItem({ newMoneyTransferToOtherParty(true) }) {
|
||||
|
|
|
@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.model.TransactionsGrouping
|
||||
import net.codinux.banking.ui.model.settings.TransactionsGrouping
|
||||
import net.codinux.banking.ui.settings.UiSettings
|
||||
import net.codinux.banking.ui.state.UiState
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
@ -69,9 +69,9 @@ fun TransactionsList(uiState: UiState, uiSettings: UiSettings, isMobile: Boolean
|
|||
} else {
|
||||
LazyColumn(transactionsListModifier, contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp)) {
|
||||
itemsIndexed(holdingsToDisplay) { index, holding ->
|
||||
// key(holding.isin) {
|
||||
key(holding.id) {
|
||||
HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(transactionsToDisplay) { index, transaction ->
|
||||
|
|
|
@ -22,6 +22,9 @@ object Colors {
|
|||
val BackgroundColorLight = Color("#FFFFFF")
|
||||
|
||||
|
||||
val MaterialThemeTextColor = Color(0xFF4F4F4F) // to match dialog's text color of Material theme
|
||||
|
||||
|
||||
val DrawerContentBackground = BackgroundColorDark
|
||||
|
||||
val DrawerPrimaryText = PrimaryTextColorDark
|
||||
|
@ -34,6 +37,16 @@ object Colors {
|
|||
val CodinuxSecondaryColor = Color(251, 187, 33)
|
||||
|
||||
|
||||
val FormLabelTextColor = Color(0xFF494949)
|
||||
|
||||
val FormValueTextColor = Color(0xFF999999)
|
||||
|
||||
val FormListItemTextColor = FormLabelTextColor
|
||||
|
||||
|
||||
val DestructiveColor = Color(0xFFff3b30)
|
||||
|
||||
|
||||
val Zinc100 = Color(244, 244, 245)
|
||||
val Zinc100_50 = Zinc100.copy(alpha = 0.5f)
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
package net.codinux.banking.ui.config
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import net.codinux.banking.dataaccess.BankingRepository
|
||||
import net.codinux.banking.dataaccess.InMemoryBankingRepository
|
||||
import net.codinux.banking.dataaccess.SqliteBankingRepository
|
||||
import net.codinux.banking.persistence.BankingRepository
|
||||
import net.codinux.banking.persistence.InMemoryBankingRepository
|
||||
import net.codinux.banking.ui.Platform
|
||||
import net.codinux.banking.ui.getPlatform
|
||||
import net.codinux.banking.ui.service.*
|
||||
|
@ -31,18 +29,22 @@ object DI {
|
|||
|
||||
val accountTransactionsFilterService = AccountTransactionsFilterService()
|
||||
|
||||
val epcQrCodeService = EpcQrCodeService()
|
||||
|
||||
val uiService = UiService()
|
||||
|
||||
|
||||
var bankingRepository: BankingRepository = InMemoryBankingRepository(emptyList())
|
||||
|
||||
val bankingService by lazy { BankingService(uiState, uiSettings, bankingRepository, bankFinder) }
|
||||
val bankingService by lazy { BankingService(uiState, uiSettings, uiService, bankingRepository, bankFinder) }
|
||||
|
||||
|
||||
fun setRepository(sqlDriver: SqlDriver) = setRepository(SqliteBankingRepository(sqlDriver))
|
||||
|
||||
fun setRepository(repository: BankingRepository) {
|
||||
this.bankingRepository = repository
|
||||
|
||||
repository.getAppSettings()?.let { // otherwise it's the first app start, BankingService will take care of this case
|
||||
uiState.appSettings.value = it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package net.codinux.banking.ui.config
|
||||
|
||||
import net.codinux.banking.client.model.BankAccountType
|
||||
import net.codinux.banking.client.model.tan.ActionRequiringTan
|
||||
import net.codinux.banking.ui.model.TransactionsGrouping
|
||||
import net.codinux.banking.ui.model.settings.TransactionsGrouping
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
|
||||
object Internationalization {
|
||||
|
||||
|
@ -11,6 +13,12 @@ object Internationalization {
|
|||
|
||||
const val ErrorTransferMoney = "Überweisung konnte nicht ausgeführt werden"
|
||||
|
||||
const val ErrorReadEpcQrCode = "Überweisungsdaten konnten nicht aus dem QR Code ausgelesen werden"
|
||||
|
||||
const val ErrorSaveToDatabase = "Daten konnten nicht in der Datenbank gespeichert werden"
|
||||
|
||||
const val ErrorBiometricAuthentication = "Biometrische Authentifizierung fehlgeschlagen"
|
||||
|
||||
|
||||
fun getTextForActionRequiringTan(action: ActionRequiringTan): String = when (action) {
|
||||
ActionRequiringTan.GetAnonymousBankInfo,
|
||||
|
@ -30,4 +38,23 @@ object Internationalization {
|
|||
TransactionsGrouping.None -> "Nicht gruppieren"
|
||||
}
|
||||
|
||||
fun translate(accountType: BankAccountType): String = when (accountType) {
|
||||
BankAccountType.CheckingAccount -> "Girokonto"
|
||||
BankAccountType.SavingsAccount -> "Sparkonto"
|
||||
BankAccountType.FixedTermDepositAccount -> "Festgeldkonto"
|
||||
BankAccountType.SecuritiesAccount -> "Wertpapierdepot"
|
||||
BankAccountType.LoanAccount -> "Darlehenskonto"
|
||||
BankAccountType.CreditCardAccount -> "Kreditkartenkonto"
|
||||
BankAccountType.FundDeposit -> "Fondsdepot"
|
||||
BankAccountType.BuildingLoanContract -> "Bausparvertrag"
|
||||
BankAccountType.InsuranceContract -> "Versicherungsvertrag"
|
||||
BankAccountType.Other -> "Sonstige"
|
||||
}
|
||||
|
||||
fun translate(authenticationMethod: AppAuthenticationMethod): String = when (authenticationMethod) {
|
||||
AppAuthenticationMethod.None -> "Ungeschützt"
|
||||
AppAuthenticationMethod.Password -> "Passwort"
|
||||
AppAuthenticationMethod.Biometric -> "Biometrie"
|
||||
}
|
||||
|
||||
}
|
|
@ -19,6 +19,15 @@ object Style {
|
|||
val ListItemHeaderWeight = FontWeight.Medium // couldn't believe it, the FontWeights look different on Desktop and Android
|
||||
|
||||
|
||||
val FabSize = 56.dp
|
||||
|
||||
val SmallFabSize = 46.dp
|
||||
|
||||
val FabSpacing = 16.dp
|
||||
|
||||
val FabMenuSpacing = FabSpacing / 2
|
||||
|
||||
|
||||
val DividerThickness = 1.dp
|
||||
|
||||
}
|
|
@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.*
|
||||
|
@ -20,9 +19,11 @@ import net.codinux.banking.ui.IOorDefault
|
|||
import net.codinux.banking.ui.composables.BankIcon
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.extensions.ImeNext
|
||||
import net.codinux.banking.ui.forms.*
|
||||
import net.codinux.banking.ui.forms.OutlinedTextField
|
||||
import net.codinux.banking.ui.model.BankInfo
|
||||
import net.codinux.banking.bankfinder.BankInfo
|
||||
import net.codinux.log.Log
|
||||
|
||||
|
||||
private val bankingService = DI.bankingService
|
||||
|
@ -37,7 +38,7 @@ fun AddAccountDialog(
|
|||
var selectedBank by remember { mutableStateOf<BankInfo?>(null) }
|
||||
var loginName by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var retrieveAllTransactions by remember { mutableStateOf(false) }
|
||||
var retrieveAllTransactions by remember { mutableStateOf(true) }
|
||||
|
||||
val isRequiredDataEntered by remember(selectedBank, loginName, password) {
|
||||
derivedStateOf { selectedBank != null && loginName.length > 3 && password.length > 3 }
|
||||
|
@ -69,7 +70,12 @@ fun AddAccountDialog(
|
|||
isAddingAccount = true
|
||||
|
||||
addAccountJob = coroutineScope.launch(Dispatchers.IOorDefault) {
|
||||
val successful = DI.bankingService.addAccount(bank, loginName, password, retrieveAllTransactions)
|
||||
val successful = try {
|
||||
DI.bankingService.addAccount(bank, loginName, password, retrieveAllTransactions)
|
||||
} catch (e: Throwable) {
|
||||
Log.error(e) { "Could not add account for $bank" }
|
||||
false
|
||||
}
|
||||
|
||||
addAccountJob = null
|
||||
|
||||
|
@ -136,9 +142,11 @@ fun AddAccountDialog(
|
|||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(top = 6.dp)) {
|
||||
Text(bank.domesticBankCode, color = textColor)
|
||||
Text(bank.bankCode, color = textColor)
|
||||
|
||||
Text("${bank.postalCode} ${bank.city}", Modifier.weight(1f).padding(start = 8.dp), color = if (supportsFinTs) Color.Gray else textColor)
|
||||
Text((bank.bic ?: "").padEnd(11, ' '), color = textColor, modifier = Modifier.padding(horizontal = 8.dp))
|
||||
|
||||
Text("${bank.postalCode} ${bank.city}", Modifier.weight(1f), color = if (supportsFinTs) Color.Gray else textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -155,7 +163,7 @@ fun AddAccountDialog(
|
|||
onValueChange = { loginName = it },
|
||||
label = { Text("Login Name") },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(loginNameFocus),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
keyboardOptions = KeyboardOptions.ImeNext
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
|
|
@ -11,9 +11,10 @@ fun ApplicationErrorDialog(error: ApplicationError, onDismiss: (() -> Unit)? = n
|
|||
ErroneousAction.AddAccount -> Internationalization.ErrorAddAccount
|
||||
ErroneousAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions
|
||||
ErroneousAction.TransferMoney -> Internationalization.ErrorTransferMoney
|
||||
ErroneousAction.ReadEpcQrCode -> Internationalization.ErrorReadEpcQrCode
|
||||
ErroneousAction.SaveToDatabase -> Internationalization.ErrorSaveToDatabase
|
||||
ErroneousAction.BiometricAuthentication -> Internationalization.ErrorBiometricAuthentication
|
||||
}
|
||||
|
||||
// add exception stacktrace?
|
||||
|
||||
ErrorDialog(error.errorMessage, title, onDismiss = onDismiss)
|
||||
ErrorDialog(error.errorMessage, title, error.exception, onDismiss = onDismiss)
|
||||
}
|
|
@ -3,9 +3,12 @@ package net.codinux.banking.ui.dialogs
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
@ -13,17 +16,25 @@ 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 net.codinux.banking.ui.PlatformType
|
||||
import net.codinux.banking.ui.addKeyboardVisibilityListener
|
||||
import net.codinux.banking.ui.composables.CloseButton
|
||||
import net.codinux.banking.ui.composables.text.HeaderText
|
||||
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.extensions.applyPlatformSpecificPaddingIf
|
||||
import net.codinux.banking.ui.extensions.copy
|
||||
import net.codinux.banking.ui.extensions.verticalScroll
|
||||
import net.codinux.banking.ui.forms.*
|
||||
|
||||
@Composable
|
||||
fun BaseDialog(
|
||||
title: String,
|
||||
centerTitle: Boolean = false,
|
||||
confirmButtonTitle: String = "OK",
|
||||
confirmButtonEnabled: Boolean = true,
|
||||
dismissButtonTitle: String = "Abbrechen",
|
||||
showProgressIndicatorOnConfirmButton: Boolean = false,
|
||||
useMoreThanPlatformDefaultWidthOnMobile: Boolean = false,
|
||||
onDismiss: () -> Unit,
|
||||
|
@ -33,25 +44,26 @@ fun BaseDialog(
|
|||
) {
|
||||
val overwriteDefaultWidth = useMoreThanPlatformDefaultWidthOnMobile && DI.platform.isMobile
|
||||
|
||||
var isKeyboardVisible by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, if (overwriteDefaultWidth) properties.copy(usePlatformDefaultWidth = false) else properties) {
|
||||
RoundedCornersCard(Modifier.let { if (overwriteDefaultWidth) it.fillMaxWidth(0.95f) else it }) {
|
||||
Column(Modifier.background(Color.White).padding(8.dp)) {
|
||||
Column(Modifier.applyPlatformSpecificPaddingIf(overwriteDefaultWidth && isKeyboardVisible, 8.dp).background(Color.White).padding(horizontal = 8.dp).verticalScroll()) {
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f))
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp).height(32.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
HeaderText(title, Modifier.fillMaxWidth().weight(1f), textColor = Style.ListItemHeaderTextColor, textAlign = if (centerTitle) TextAlign.Center else TextAlign.Start)
|
||||
|
||||
if (DI.platform.isDesktop) {
|
||||
TextButton(onDismiss, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close dialog", Modifier.size(32.dp))
|
||||
}
|
||||
if (DI.platform.type != PlatformType.Android) { // for iOS it's also relevant due to the missing back gesture / back button
|
||||
CloseButton(onClick = onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
content()
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
TextButton(onClick = onDismiss, Modifier.weight(0.5f)) {
|
||||
Text("Abbrechen", color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
Text(dismissButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
TextButton(
|
||||
|
@ -61,7 +73,7 @@ fun BaseDialog(
|
|||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (showProgressIndicatorOnConfirmButton) {
|
||||
CircularProgressIndicator(Modifier.padding(end = 6.dp), color = Colors.CodinuxSecondaryColor)
|
||||
CircularProgressIndicator(Modifier.padding(end = 6.dp).size(36.dp), color = Colors.CodinuxSecondaryColor)
|
||||
}
|
||||
|
||||
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
|
@ -71,4 +83,13 @@ fun BaseDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (DI.platform.type == PlatformType.iOS) { // on iOS top dialog part gets hidden by top system bar when soft keyboard is visible -> apply system padding then
|
||||
addKeyboardVisibilityListener { visible ->
|
||||
isKeyboardVisible = visible
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package net.codinux.banking.ui.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
text: String,
|
||||
title: String? = null,
|
||||
confirmButtonTitle: String = "Ja",
|
||||
dismissButtonTitle: String = "Nein",
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
|
||||
BaseDialog(
|
||||
title = title ?: "",
|
||||
centerTitle = true,
|
||||
confirmButtonTitle = confirmButtonTitle,
|
||||
dismissButtonTitle = dismissButtonTitle,
|
||||
onDismiss = { onDismiss() },
|
||||
onConfirm = { onConfirm(); onDismiss() }
|
||||
) {
|
||||
Text(text, textAlign = TextAlign.Center, lineHeight = 22.sp, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp))
|
||||
}
|
||||
}
|
|
@ -1,33 +1,34 @@
|
|||
package net.codinux.banking.ui.dialogs
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ZoomIn
|
||||
import androidx.compose.material.icons.filled.ZoomOut
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import net.codinux.banking.client.model.tan.*
|
||||
import net.codinux.banking.client.model.tan.ActionRequiringTan
|
||||
import net.codinux.banking.client.model.tan.AllowedTanFormat
|
||||
import net.codinux.banking.client.model.tan.EnterTanResult
|
||||
import net.codinux.banking.ui.composables.BankIcon
|
||||
import net.codinux.banking.ui.composables.tan.ChipTanFlickerCodeView
|
||||
import net.codinux.banking.ui.composables.tan.ImageView
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.config.Internationalization
|
||||
import net.codinux.banking.ui.forms.CaptionText
|
||||
import net.codinux.banking.ui.forms.OutlinedTextField
|
||||
import net.codinux.banking.ui.forms.Select
|
||||
import net.codinux.banking.ui.model.Config.NewLine
|
||||
import net.codinux.banking.ui.model.TanChallengeReceived
|
||||
import net.codinux.banking.ui.model.error.ErroneousAction
|
||||
import net.codinux.banking.ui.service.createImageBitmap
|
||||
import net.codinux.log.Log
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
|
@ -41,9 +42,7 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
|
|||
|
||||
val isNotADecoupledTanMethod = !!!isDecoupledMethod
|
||||
|
||||
var tanImageHeight by remember { mutableStateOf(250) }
|
||||
val minTanImageHeight = 100
|
||||
val maxTanImageHeight = 500
|
||||
var showSelectingTanMediumNotImplementedWarning by remember { mutableStateOf(false) }
|
||||
|
||||
val textFieldFocus = remember { FocusRequester() }
|
||||
|
||||
|
@ -98,10 +97,10 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
|
|||
|
||||
Text("${challenge.bank.bankName}, Nutzer ${challenge.bank.loginName}${challenge.account?.let { ", Konto ${it.productName ?: it.identifier}" } ?: ""}")
|
||||
}
|
||||
Text(
|
||||
"TAN benötigt ${Internationalization.getTextForActionRequiringTan(challenge.forAction)}",
|
||||
Modifier.padding(top = 6.dp)
|
||||
)
|
||||
Row(Modifier.padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("TAN benötigt ")
|
||||
Text(Internationalization.getTextForActionRequiringTan(challenge.forAction), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -110,19 +109,9 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
|
|||
"TAN Verfahren",
|
||||
challenge.availableTanMethods.sortedBy { it.identifier },
|
||||
challenge.selectedTanMethod,
|
||||
{ tanMethod ->
|
||||
if (tanMethod.type != TanMethodType.ChipTanFlickercode) {
|
||||
tanChallengeReceived.callback(EnterTanResult(null, tanMethod))
|
||||
}
|
||||
},
|
||||
{ tanMethod -> tanChallengeReceived.callback(EnterTanResult(null, tanMethod)) },
|
||||
{ it.displayName }
|
||||
) { tanMethod ->
|
||||
if (tanMethod.type == TanMethodType.ChipTanFlickercode) {
|
||||
Text(tanMethod.displayName + " (noch nicht implementiert)", color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled))
|
||||
} else {
|
||||
Text(tanMethod.displayName)
|
||||
}
|
||||
}
|
||||
) { tanMethod -> Text(tanMethod.displayName) }
|
||||
}
|
||||
|
||||
if (challenge.availableTanMedia.isNotEmpty()) {
|
||||
|
@ -131,39 +120,35 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
|
|||
"TAN Medium",
|
||||
challenge.availableTanMedia.sortedBy { it.status }.map { it.displayName },
|
||||
challenge.selectedTanMedium?.displayName ?: "<Keines ausgewählt>",
|
||||
{ Log.info { "User selected TanMedium $it" } }, // TODO: change TanMethod
|
||||
{ showSelectingTanMediumNotImplementedWarning = true }, // TODO: change TanMedium
|
||||
{ it }
|
||||
)
|
||||
}
|
||||
|
||||
if (showSelectingTanMediumNotImplementedWarning) {
|
||||
CaptionText("Es tut uns Leid, aber das Ändern des TAN Mediums ist gegenwärtig noch nicht implementiert", Colors.DestructiveColor, Arrangement.Start)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (challenge.tanImage != null || challenge.flickerCode != null) {
|
||||
Column(Modifier.fillMaxWidth().padding(top = 6.dp)) {
|
||||
if (challenge.flickerCode != null) {
|
||||
Text("Es tut uns Leid, für die TAN müsste ein Flickercode angezeigt werden, was wir noch nicht implementiert haben.")
|
||||
Text("Bitte wählen Sie ein anderes TAN Verfahren, z. B. chipTAN-QrCode oder manuelle TAN Eingabe wie chipTAN manuell.", Modifier.padding(top = 6.dp))
|
||||
val textColor = Colors.MaterialThemeTextColor // to match dialog's text color of Material theme
|
||||
|
||||
challenge.flickerCode?.let { flickerCode ->
|
||||
ChipTanFlickerCodeView(flickerCode, textColor)
|
||||
}
|
||||
|
||||
challenge.tanImage?.let { tanImage ->
|
||||
if (tanImage.decodingSuccessful) {
|
||||
val imageBytes = Base64.decode(tanImage.imageBytesBase64)
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Größe")
|
||||
Spacer(Modifier.width(6.dp))
|
||||
TextButton({ tanImageHeight -= 25}, enabled = tanImageHeight > minTanImageHeight, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
|
||||
Icon(Icons.Filled.ZoomOut, contentDescription = "Bild mit enkodierter TAN verkleiner", Modifier.size(28.dp))
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
TextButton({ tanImageHeight += 25}, enabled = tanImageHeight < maxTanImageHeight, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
|
||||
Icon(Icons.Filled.ZoomIn, contentDescription = "Bild mit enkodierter TAN vergrößern", Modifier.size(28.dp))
|
||||
}
|
||||
tanImage.decodingError?.let {
|
||||
Text("Hier sollte eigentlich das TAN Bild angezeigt werden, dieses konnte jedoch nicht dekodiert werden:$NewLine${tanImage.decodingError}", color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(createImageBitmap(imageBytes), "Bild mit enkodierter TAN", Modifier.height(tanImageHeight.dp), contentScale = ContentScale.FillHeight)
|
||||
}
|
||||
tanImage.imageBytesBase64?.let { imageBytesBase64 ->
|
||||
val imageBytes = Base64.decode(imageBytesBase64)
|
||||
|
||||
// if it becomes necessary may also add the bank to ImageSettings.id to make ImageSettings bank specific
|
||||
ImageView(imageBytes, challenge.selectedTanMethod.type.toString(), "Bild mit enkodierter TAN", 250, 100, 500, textColor = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,23 +7,32 @@ import androidx.compose.material.TextButton
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import net.codinux.banking.ui.composables.text.HeaderText
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.Style
|
||||
import net.codinux.banking.ui.extensions.verticalScroll
|
||||
import net.codinux.banking.ui.model.Config.NewLine
|
||||
|
||||
@Composable
|
||||
fun ErrorDialog(
|
||||
text: String,
|
||||
title: String? = null,
|
||||
exception: Throwable? = null,
|
||||
confirmButtonText: String = "OK",
|
||||
onDismiss: (() -> Unit)? = null
|
||||
) {
|
||||
|
||||
val effectiveText = if (exception == null) text else {
|
||||
"$text${NewLine}${NewLine}Fehlermeldung:${NewLine}${exception.stackTraceToString()}"
|
||||
}
|
||||
|
||||
|
||||
AlertDialog(
|
||||
text = { Text(text) },
|
||||
text = { Text(effectiveText, Modifier.verticalScroll()) },
|
||||
title = { title?.let {
|
||||
HeaderText(title, Modifier.fillMaxWidth(), TextAlign.Center)
|
||||
} },
|
||||
properties = if (exception == null) DialogProperties() else DialogProperties(usePlatformDefaultWidth = false),
|
||||
onDismissRequest = { onDismiss?.invoke() },
|
||||
confirmButton = {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
|
|
|
@ -20,7 +20,9 @@ import net.codinux.banking.ui.IOorDefault
|
|||
import net.codinux.banking.ui.composables.BankIcon
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.extensions.ImeNext
|
||||
import net.codinux.banking.ui.forms.AutocompleteTextField
|
||||
import net.codinux.banking.ui.forms.CaptionText
|
||||
import net.codinux.banking.ui.forms.OutlinedTextField
|
||||
import net.codinux.banking.ui.forms.Select
|
||||
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
|
||||
|
@ -40,10 +42,9 @@ fun TransferMoneyDialog(
|
|||
) {
|
||||
val banks = uiState.banks.value
|
||||
val accountsToBank = banks.sortedBy { it.displayIndex }
|
||||
.flatMap { bank -> bank.accounts.sortedBy { it.displayIndex }.map { it to bank } }.toMap()
|
||||
.flatMap { bank -> bank.accountsSorted.map { it to bank } }.toMap()
|
||||
|
||||
val accountsSupportingTransferringMoney = banks.flatMap { it.accounts }
|
||||
.filter { it.supportsMoneyTransfer }
|
||||
val accountsSupportingTransferringMoney = uiState.accountsThatSupportMoneyTransfer.collectAsState().value
|
||||
|
||||
if (accountsSupportingTransferringMoney.isEmpty()) {
|
||||
uiState.applicationErrorOccurred(ErroneousAction.TransferMoney, "Keines Ihrer Konten unterstützt das Überweisen von Geld")
|
||||
|
@ -56,7 +57,7 @@ fun TransferMoneyDialog(
|
|||
|
||||
var recipientName by remember { mutableStateOf(data.recipientName ?: "") }
|
||||
var recipientAccountIdentifier by remember { mutableStateOf(data.recipientAccountIdentifier ?: "") }
|
||||
var amount by remember { mutableStateOf(data.amount.toString()) }
|
||||
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) }
|
||||
|
@ -76,6 +77,8 @@ fun TransferMoneyDialog(
|
|||
|
||||
val amountFocus = remember { FocusRequester() }
|
||||
|
||||
val referenceFocus = remember { FocusRequester() }
|
||||
|
||||
val verticalSpace = 8.dp
|
||||
|
||||
var isInitialized by remember { mutableStateOf(false) }
|
||||
|
@ -120,7 +123,7 @@ fun TransferMoneyDialog(
|
|||
|
||||
|
||||
BaseDialog(
|
||||
title = "Neue Überweisung ...",
|
||||
title = "Neue Überweisung",
|
||||
confirmButtonTitle = "Überweisen",
|
||||
confirmButtonEnabled = isRequiredDataEntered && isTransferringMoney == false,
|
||||
showProgressIndicatorOnConfirmButton = isTransferringMoney,
|
||||
|
@ -184,6 +187,8 @@ fun TransferMoneyDialog(
|
|||
}
|
||||
}
|
||||
|
||||
CaptionText("${recipientName.length} / 70")
|
||||
|
||||
Spacer(modifier = Modifier.height(verticalSpace))
|
||||
|
||||
OutlinedTextField(
|
||||
|
@ -191,7 +196,7 @@ fun TransferMoneyDialog(
|
|||
onValueChange = { recipientAccountIdentifier = it },
|
||||
label = { Text("IBAN") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
keyboardOptions = KeyboardOptions.ImeNext
|
||||
)
|
||||
|
||||
Row(Modifier.padding(vertical = verticalSpace).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
@ -205,12 +210,13 @@ fun TransferMoneyDialog(
|
|||
getItemTitle = { suggestion -> suggestion.amount.toString() },
|
||||
onEnteredTextChanged = { amount = it },
|
||||
onSelectedItemChanged = {
|
||||
amount = it?.amount.toString()
|
||||
if (it != null) {
|
||||
amount = it.amount.toString()
|
||||
paymentReference = it.reference
|
||||
referenceFocus.requestFocus()
|
||||
}
|
||||
},
|
||||
fetchSuggestions = { recipientFinder.findPaymentDataForIban(recipientAccountIdentifier) }
|
||||
fetchSuggestions = { query -> recipientFinder.findAmountPaymentDataForIban(recipientAccountIdentifier, query) }
|
||||
) { paymentDataSuggestion ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(formatUtil.formatAmount(paymentDataSuggestion.amount, paymentDataSuggestion.currency), Modifier.widthIn(min = 60.dp), textAlign = TextAlign.End)
|
||||
|
@ -225,12 +231,13 @@ fun TransferMoneyDialog(
|
|||
AutocompleteTextField(
|
||||
"Verwendungszweck (optional)",
|
||||
paymentReference,
|
||||
dropdownMaxHeight = 250.dp,
|
||||
minTextLengthForSearch = 0,
|
||||
dropdownMaxHeight = 175.dp, // when showing more items than on Android autocomplete dropdown covers soft keyboard
|
||||
minTextLengthForSearch = 1,
|
||||
modifier = Modifier.focusRequester(referenceFocus),
|
||||
getItemTitle = { suggestion -> suggestion.reference },
|
||||
onEnteredTextChanged = { paymentReference = it },
|
||||
onSelectedItemChanged = { paymentReference = it?.reference ?: "" },
|
||||
fetchSuggestions = { recipientFinder.findPaymentDataForIban(recipientAccountIdentifier) }
|
||||
fetchSuggestions = { query -> recipientFinder.findReferencePaymentDataForIban(recipientAccountIdentifier, query) }
|
||||
) { paymentDataSuggestion ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(formatUtil.formatAmount(paymentDataSuggestion.amount, paymentDataSuggestion.currency), Modifier.widthIn(min = 60.dp), textAlign = TextAlign.End)
|
||||
|
@ -239,12 +246,7 @@ fun TransferMoneyDialog(
|
|||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.End) {
|
||||
Text(
|
||||
text = "${paymentReference.length} / 140",
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
CaptionText("${paymentReference.length} / 140")
|
||||
|
||||
|
||||
Row(Modifier.padding(top = verticalSpace), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
@ -273,7 +275,13 @@ fun TransferMoneyDialog(
|
|||
coroutineScope.launch {
|
||||
recipientFinder.updateData(bankingService.getAllAccountTransactions()) // only a bit problematic: if in the meantime new transactions are retrieved, then RecipientFinder doesn't contain the newly retrieved transactions
|
||||
|
||||
if (data.recipientName.isNullOrBlank()) {
|
||||
recipientNameFocus.requestFocus()
|
||||
} else if (data.amount == null) {
|
||||
amountFocus.requestFocus()
|
||||
} else {
|
||||
referenceFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package net.codinux.banking.ui.extensions
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
|
||||
|
||||
val KeyboardOptions.Companion.ImeNext: KeyboardOptions
|
||||
get() = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
|
||||
val KeyboardOptions.Companion.ImeDone: KeyboardOptions
|
||||
get() = KeyboardOptions(imeAction = ImeAction.Done)
|
|
@ -0,0 +1,49 @@
|
|||
package net.codinux.banking.ui.extensions
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.systemPaddings
|
||||
import net.codinux.log.Log
|
||||
|
||||
|
||||
fun Modifier.verticalScroll() = this.verticalScroll(ScrollState(0), enabled = true)
|
||||
|
||||
@Composable
|
||||
fun Modifier.rememberVerticalScroll() = this.verticalScroll(rememberScrollState())
|
||||
|
||||
fun Modifier.horizontalScroll() = this.horizontalScroll(ScrollState(0), enabled = true)
|
||||
|
||||
@Composable
|
||||
fun Modifier.rememberHorizontalScroll() = this.horizontalScroll(rememberScrollState())
|
||||
|
||||
|
||||
@Composable
|
||||
// we need to support three different cases:
|
||||
// - normal, non fullscreen dialog, either useMoreThanPlatformDefaultWidthOnMobile is false or soft keyboard is hidden -> apply default vertical padding
|
||||
// - normal, non fullscreen dialog, useMoreThanPlatformDefaultWidthOnMobile is true and soft keyboard is visible = applyPlatformPadding == true -> on iOS apply platform padding as
|
||||
// otherwise dialog title gets hidden by upper system bar, on all other platforms default vertical padding
|
||||
// - fullscreen dialog -> on iOS apply platform padding as otherwise dialog title gets hidden by upper system bar, on all other platforms default vertical padding
|
||||
fun Modifier.applyPlatformSpecificPaddingIf(applyPlatformPadding: Boolean, minVerticalPadding: Dp = 0.dp): Modifier =
|
||||
if (applyPlatformPadding) {
|
||||
this.applyPlatformSpecificPadding(minVerticalPadding)
|
||||
} else if (minVerticalPadding > 0.dp) {
|
||||
this.padding(vertical = minVerticalPadding)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.applyPlatformSpecificPadding(minVerticalPadding: Dp = 0.dp): Modifier {
|
||||
val systemPaddings = systemPaddings()
|
||||
|
||||
return this.padding(
|
||||
top = maxOf(minVerticalPadding, systemPaddings.calculateTopPadding()),
|
||||
bottom = maxOf(minVerticalPadding, systemPaddings.calculateBottomPadding())
|
||||
).also {
|
||||
Log.info { "Applied padding: ${systemPaddings.calculateTopPadding()}, ${systemPaddings.calculateBottomPadding()}" }
|
||||
}
|
||||
}
|
|
@ -30,6 +30,7 @@ fun <T> AutocompleteTextField(
|
|||
modifier: Modifier = Modifier,
|
||||
textFieldFocus: FocusRequester = remember { FocusRequester() },
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
onEnterPressed: (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
fetchSuggestions: suspend (query: String) -> Collection<T> = { emptyList() },
|
||||
suggestionContent: @Composable (T) -> Unit
|
||||
|
@ -102,7 +103,8 @@ fun <T> AutocompleteTextField(
|
|||
)
|
||||
}
|
||||
},
|
||||
leadingIcon = leadingIcon
|
||||
leadingIcon = leadingIcon,
|
||||
onEnterPressed = onEnterPressed
|
||||
)
|
||||
|
||||
// due to a bug (still not fixed since 2021) in ExposedDropdownMenu its popup has a maximum width of 800 pixel / 320dp which is too less to fit
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun CaptionText(text: String, color: Color = Color.Unspecified, horizontalArrangement: Arrangement.Horizontal = Arrangement.End) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = horizontalArrangement) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
|
||||
@Composable
|
||||
fun FormListItem(label: String, itemHeight: Dp = 32.dp, onClick: (() -> Unit)? = null) {
|
||||
FormListItemImpl(label, itemHeight = itemHeight, onClick = onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectableFormListItem(
|
||||
label: String,
|
||||
isSelected: Boolean = false,
|
||||
selectedIconContentDescription: String? = null,
|
||||
itemHeight: Dp = 32.dp,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
FormListItemImpl(label, true, isSelected, selectedIconContentDescription, itemHeight, onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormListItemImpl(
|
||||
label: String,
|
||||
isSelectable: Boolean = false,
|
||||
isSelected: Boolean = false,
|
||||
selectedIconContentDescription: String? = null,
|
||||
itemHeight: Dp = 32.dp,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
|
||||
Row(Modifier.fillMaxWidth().height(itemHeight).clickable { onClick?.invoke() }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isSelectable) {
|
||||
Column(Modifier.padding(end = 8.dp).size(24.dp)) {
|
||||
if (isSelected) {
|
||||
Icon(Icons.Outlined.Check, selectedIconContentDescription ?: "Item ist ausgewählt", tint = Colors.FormListItemTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(label, color = Colors.FormListItemTextColor, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
|
||||
@Composable
|
||||
fun LabelledValue(label: String, value: String?, valueTextColor: Color? = null, labelMaxLines: Int = 1) {
|
||||
|
||||
if (value != null) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 2.dp),
|
||||
fontSize = 15.sp,
|
||||
color = Colors.FormLabelTextColor,
|
||||
maxLines = labelMaxLines,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = value,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
fontSize = 15.sp,
|
||||
color = valueTextColor ?: Colors.FormValueTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -17,7 +17,16 @@ import androidx.compose.ui.text.input.VisualTransformation
|
|||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable // try BasicSecureTextField
|
||||
fun PasswordTextField(password: String = "", label: String = "Passwort", forceHidePassword: Boolean? = null, onEnterPressed: (() -> Unit)? = null, onChange: (String) -> Unit) {
|
||||
fun PasswordTextField(
|
||||
password: String = "",
|
||||
label: String = "Passwort",
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardOptions: KeyboardOptions? = null,
|
||||
isError: Boolean = false,
|
||||
forceHidePassword: Boolean? = null,
|
||||
onEnterPressed: (() -> Unit)? = null,
|
||||
onChange: (String) -> Unit
|
||||
) {
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
|
@ -29,7 +38,8 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
|
|||
value = password,
|
||||
onValueChange = { onChange(it) },
|
||||
label = { Text(label) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
isError = isError,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
val visibilityIcon = if (passwordVisible) {
|
||||
|
@ -43,7 +53,7 @@ fun PasswordTextField(password: String = "", label: String = "Passwort", forceHi
|
|||
modifier = Modifier.size(24.dp).clickable { passwordVisible = !passwordVisible }
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
keyboardOptions = keyboardOptions?.copy(keyboardType = KeyboardType.Password) ?: KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
onEnterPressed = onEnterPressed
|
||||
)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
|
||||
@Composable
|
||||
fun SectionHeader(title: String, topPadding: Boolean = true) {
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.fillMaxWidth().let {
|
||||
if (topPadding) {
|
||||
it.padding(top = 24.dp)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
color = Colors.CodinuxSecondaryColor,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package net.codinux.banking.ui.forms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
|
||||
@Composable
|
||||
fun <T> SegmentedControl(
|
||||
options: Collection<T>,
|
||||
selectedOption: T,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Colors.Accent,
|
||||
cornerSize: Dp = 8.dp,
|
||||
getOptionDisplayText: ((T) -> String)? = null,
|
||||
onOptionSelected: (T) -> Unit
|
||||
) {
|
||||
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
Row(modifier.height(48.dp).border(2.dp, color, RoundedCornerShape(cornerSize))) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selectedOption
|
||||
val backgroundColor = if (isSelected) color else Color.Transparent
|
||||
val textColor = if (isSelected) Color.White else color
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable { onOptionSelected(option) }
|
||||
.fillMaxHeight()
|
||||
.weight(1f)
|
||||
.let {
|
||||
if (index == 0) {
|
||||
it.background(backgroundColor, RoundedCornerShape(topStart = cornerSize, bottomStart = cornerSize))
|
||||
} else if (index == options.size - 1) {
|
||||
it.background(backgroundColor, RoundedCornerShape(topEnd = cornerSize, bottomEnd = cornerSize))
|
||||
} else {
|
||||
it.background(backgroundColor)
|
||||
}
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = getOptionDisplayText?.invoke(option) ?: option.toString(),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
if (index < options.size - 1) {
|
||||
Divider(Modifier.fillMaxHeight().width(1.dp), color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import net.codinux.banking.dataaccess.entities.BankAccountEntity
|
||||
import net.codinux.banking.dataaccess.entities.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
|
||||
class AccountTransactionsFilter {
|
||||
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
class AuthenticationResult(
|
||||
val successful: Boolean,
|
||||
val error: String? = null
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
return if (successful) {
|
||||
"Successful"
|
||||
}
|
||||
else {
|
||||
"Error occurred: $error"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
import net.codinux.banking.dataaccess.entities.BankAccountEntity
|
||||
import net.codinux.banking.dataaccess.entities.BankAccessEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
|
||||
data class BankAccountFilter(
|
||||
val bank: BankAccessEntity,
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
import net.codinux.banking.client.model.BankingGroup
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class BankInfo(
|
||||
val name: String,
|
||||
val domesticBankCode: String,
|
||||
val bic: String = "",
|
||||
val postalCode: String,
|
||||
val city: String,
|
||||
val pinTanAddress: String? = null,
|
||||
val pinTanVersion: String? = null,
|
||||
val bankingGroup: BankingGroup? = null,
|
||||
val branchesInOtherCities: List<String> = listOf() // to have only one entry per bank its branches' cities are now stored in branchesInOtherCities so that branches' cities are still searchable
|
||||
) {
|
||||
|
||||
val supportsPinTan: Boolean
|
||||
get() = pinTanAddress.isNullOrEmpty() == false
|
||||
|
||||
val supportsFinTs3_0: Boolean
|
||||
get() = pinTanVersion == "FinTS V3.0"
|
||||
|
||||
|
||||
override fun toString() = "$domesticBankCode $name $city"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
data class DecodeEpcQrCodeResult(
|
||||
val data: ShowTransferMoneyDialogData?,
|
||||
val error: String? = null,
|
||||
val charset: String? = null
|
||||
)
|
|
@ -1,5 +1,7 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
import net.codinux.banking.bankfinder.BankInfo
|
||||
|
||||
data class RecipientSuggestion(
|
||||
val name: String,
|
||||
val bankIdentifier: String?,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
import net.codinux.banking.client.model.Amount
|
||||
import net.codinux.banking.dataaccess.entities.BankAccountEntity
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
|
||||
data class ShowTransferMoneyDialogData(
|
||||
val senderAccount: BankAccountEntity? = null,
|
||||
|
|
|
@ -5,5 +5,11 @@ enum class ErroneousAction {
|
|||
|
||||
UpdateAccountTransactions,
|
||||
|
||||
TransferMoney
|
||||
TransferMoney,
|
||||
|
||||
ReadEpcQrCode,
|
||||
|
||||
SaveToDatabase,
|
||||
|
||||
BiometricAuthentication
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.client.model.isNegative
|
||||
import net.codinux.banking.persistence.entities.AccountTransactionEntity
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.extensions.rememberVerticalScroll
|
||||
import net.codinux.banking.ui.forms.LabelledValue
|
||||
import net.codinux.banking.ui.forms.OutlinedTextField
|
||||
import net.codinux.banking.ui.forms.SectionHeader
|
||||
|
||||
private val formatUtil = DI.formatUtil
|
||||
|
||||
@Composable
|
||||
fun AccountTransactionDetailsScreen(transaction: AccountTransactionEntity, onClosed: () -> Unit) {
|
||||
|
||||
val isExpense = transaction.amount.isNegative
|
||||
|
||||
val account = DI.uiState.banks.value.firstOrNull { it.id == transaction.bankId }?.accounts?.firstOrNull { it.id == transaction.accountId }
|
||||
|
||||
val accountCurrency = account?.currency ?: transaction.currency // transaction.currency just as fallback
|
||||
|
||||
val showColoredAmounts = DI.uiSettings.showColoredAmounts.collectAsState()
|
||||
|
||||
val hasDetailedValues = transaction.customerReference != null ||
|
||||
transaction.endToEndReference != null || transaction.mandateReference != null
|
||||
|| transaction.creditorIdentifier != null || transaction.originatorsIdentificationCode != null
|
||||
|| transaction.compensationAmount != null || transaction.originalAmount != null
|
||||
|| transaction.deviantOriginator != null || transaction.deviantRecipient != null
|
||||
|| transaction.referenceWithNoSpecialType != null
|
||||
|| transaction.journalNumber != null || transaction.textKeyAddition != null
|
||||
|
||||
|
||||
var enteredOtherPartyName by remember { mutableStateOf(transaction.displayedOtherPartyName ?: "") }
|
||||
|
||||
var enteredReference by remember { mutableStateOf(transaction.displayedReference ?: "") }
|
||||
|
||||
var enteredNotes by remember { mutableStateOf(transaction.notes ?: "") }
|
||||
|
||||
val hasDataChanged by remember(enteredOtherPartyName, enteredReference, enteredNotes) {
|
||||
mutableStateOf(
|
||||
(enteredOtherPartyName != transaction.userSetOtherPartyName && (transaction.userSetOtherPartyName?.isNotBlank() == true || enteredOtherPartyName.isNotBlank()))
|
||||
|| (enteredReference != transaction.userSetReference && (transaction.userSetReference?.isNotBlank() == true || enteredReference.isNotBlank()))
|
||||
|| (enteredNotes != transaction.notes && enteredNotes.isNotBlank())
|
||||
)
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun saveChanges() {
|
||||
coroutineScope.launch {
|
||||
DI.bankingService.updateAccountTransactionEntity(transaction, enteredOtherPartyName.takeUnless { it.isBlank() }, enteredReference.takeUnless { it.isBlank() }, enteredNotes.takeUnless { it.isBlank() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
FullscreenViewBase(
|
||||
"Umsatzdetails",
|
||||
confirmButtonTitle = "Speichern",
|
||||
confirmButtonEnabled = hasDataChanged,
|
||||
showDismissButton = true,
|
||||
onConfirm = { saveChanges() },
|
||||
onClosed = onClosed
|
||||
) {
|
||||
SelectionContainer {
|
||||
Column(Modifier.fillMaxSize().rememberVerticalScroll()) {
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SectionHeader(if (isExpense) "Empfänger*in" else "Zahlende*r", false)
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("Name") },
|
||||
value = enteredOtherPartyName,
|
||||
onValueChange = { enteredOtherPartyName = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
LabelledValue("BIC", transaction.otherPartyBankId ?: "")
|
||||
|
||||
LabelledValue("IBAN", transaction.otherPartyAccountId ?: "")
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SectionHeader("Betrag, Datum und Verwendungszweck")
|
||||
|
||||
LabelledValue("Betrag", formatUtil.formatAmount(transaction.amount, transaction.currency),
|
||||
formatUtil.getColorForAmount(transaction.amount, showColoredAmounts.value))
|
||||
|
||||
LabelledValue("Buchungstext", transaction.postingText ?: "")
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("Verwendungszweck") },
|
||||
value = enteredReference,
|
||||
onValueChange = { enteredReference = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
)
|
||||
|
||||
LabelledValue("Buchungsdatum", formatUtil.formatDate(transaction.bookingDate))
|
||||
|
||||
LabelledValue("Wertstellungsdatum", formatUtil.formatDate(transaction.valueDate))
|
||||
|
||||
transaction.openingBalance?.let {
|
||||
LabelledValue("Tagesanfangssaldo", formatUtil.formatAmount(it, accountCurrency))
|
||||
}
|
||||
|
||||
transaction.closingBalance?.let {
|
||||
LabelledValue("Tagesendsaldo", formatUtil.formatAmount(it, accountCurrency))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("Notizen") },
|
||||
value = enteredNotes,
|
||||
onValueChange = { enteredNotes = it },
|
||||
singleLine = false,
|
||||
minLines = 2,
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (hasDetailedValues) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SectionHeader("(Lastschrift-)Details")
|
||||
|
||||
LabelledValue("Kundenreferenz", transaction.customerReference)
|
||||
LabelledValue("Bankreferenz", transaction.bankReference)
|
||||
LabelledValue("Währungsart und Umsatzbetrag in Ursprungswährung", transaction.furtherInformation)
|
||||
|
||||
LabelledValue("Ende-zu-Ende Referenz", transaction.endToEndReference)
|
||||
LabelledValue("Mandatsreferenz", transaction.mandateReference)
|
||||
LabelledValue("Gläubiger-Identifikationsnummer", transaction.creditorIdentifier)
|
||||
LabelledValue("Kennung des Auftraggebers", transaction.originatorsIdentificationCode)
|
||||
|
||||
LabelledValue("Betrag der ursprünglichen Lastschrift", transaction.originalAmount)
|
||||
LabelledValue("Rücklastschrift Auslagenersatz und Bearbeitungsprovision", transaction.compensationAmount)
|
||||
LabelledValue("Abweichender Überweisender oder Zahlungsempfänger", transaction.deviantOriginator)
|
||||
LabelledValue("Abweichender Zahlungsempfänger oder Zahlungspflichtiger", transaction.deviantRecipient)
|
||||
LabelledValue("Verwendungszweck ohne spezielle Bedeutung", transaction.referenceWithNoSpecialType)
|
||||
|
||||
LabelledValue("Auszugsnummer", transaction.statementNumber?.toString())
|
||||
LabelledValue("Blattnummer", transaction.sheetNumber?.toString())
|
||||
|
||||
LabelledValue("Primanoten-Nr.", transaction.journalNumber)
|
||||
// LabelledValue("Kundenreferenz", transaction.textKeyAddition)
|
||||
|
||||
LabelledValue("Referenznummer", transaction.orderReferenceNumber)
|
||||
LabelledValue("Bezugsreferenz", transaction.referenceNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.config.Internationalization
|
||||
import net.codinux.banking.ui.extensions.verticalScroll
|
||||
import net.codinux.banking.ui.forms.*
|
||||
|
||||
@Composable
|
||||
fun BankAccountSettingsScreen(account: BankAccountEntity, onClosed: () -> Unit) {
|
||||
|
||||
var enteredAccountName by remember { mutableStateOf(account.displayName) }
|
||||
|
||||
var selectedHideAccount by remember { mutableStateOf(account.hideAccount) }
|
||||
|
||||
var selectedIncludeInAutomaticAccountsUpdate by remember { mutableStateOf(account.includeInAutomaticAccountsUpdate) }
|
||||
|
||||
val hasDataChanged by remember(enteredAccountName, selectedHideAccount, selectedIncludeInAutomaticAccountsUpdate) {
|
||||
mutableStateOf(
|
||||
enteredAccountName != account.displayName
|
||||
|| selectedHideAccount != account.hideAccount
|
||||
|| selectedIncludeInAutomaticAccountsUpdate != account.includeInAutomaticAccountsUpdate
|
||||
)
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun saveChanges() {
|
||||
coroutineScope.launch {
|
||||
DI.bankingService.updateAccount(account, enteredAccountName, selectedHideAccount, selectedIncludeInAutomaticAccountsUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
FullscreenViewBase(
|
||||
account.displayName,
|
||||
confirmButtonTitle = "Speichern",
|
||||
confirmButtonEnabled = hasDataChanged,
|
||||
showDismissButton = true,
|
||||
onConfirm = { saveChanges() },
|
||||
onClosed = onClosed
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll()) {
|
||||
Column {
|
||||
SectionHeader("Einstellungen", false)
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("Name") },
|
||||
value = enteredAccountName,
|
||||
onValueChange = { enteredAccountName = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp)
|
||||
)
|
||||
|
||||
BooleanOption("Bei Kontoaktualisierung einbeziehen (autom. Kontoaktualisierung noch nicht umgesetzt)", selectedIncludeInAutomaticAccountsUpdate) { selectedIncludeInAutomaticAccountsUpdate = it }
|
||||
|
||||
BooleanOption("Konto ausblenden", selectedHideAccount) { selectedHideAccount = it }
|
||||
}
|
||||
|
||||
SelectionContainer {
|
||||
Column {
|
||||
SectionHeader("Kontodaten") // TODO: add a share icon to copy data
|
||||
|
||||
LabelledValue("Kontoinhaber", account.accountHolderName)
|
||||
|
||||
LabelledValue("Kontonummer", account.identifier)
|
||||
|
||||
LabelledValue("Unterkontenmerkmal", account.subAccountNumber)
|
||||
|
||||
LabelledValue("IBAN", account.iban ?: "")
|
||||
|
||||
LabelledValue("Typ", Internationalization.translate(account.type))
|
||||
|
||||
LabelledValue("Anzahl Tage, für die Umsätze auf dem Server vorgehalten werden", account.serverTransactionsRetentionDays?.toString() ?: "<unbekannt>", labelMaxLines = 2)
|
||||
|
||||
LabelledValue("Umsätze zum letzten Mal abgerufen am", account.lastAccountUpdateTime?.let { DI.formatUtil.formatDateTime(it) } ?: "<Noch nicht abgerufen>" )
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
SectionHeader("Unterstützt")
|
||||
|
||||
Column(Modifier.padding(top = 8.dp)) {
|
||||
SelectableFormListItem("Kontostand abrufen", account.supportsBalanceRetrieval, "Unterstützt das Abrufen des Kontostandes")
|
||||
SelectableFormListItem("Kontoumsätze abrufen", account.supportsTransactionRetrieval, "Unterstützt das Abrufen der Kontoumsätze")
|
||||
SelectableFormListItem("Überweisen", account.supportsMoneyTransfer, "Unterstützt Überweisungen")
|
||||
SelectableFormListItem("Echtzeitüberweisung", account.supportsInstantTransfer, "Unterstützt Echtzeitüberweisungen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.persistence.entities.BankAccessEntity
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.dialogs.ConfirmDialog
|
||||
import net.codinux.banking.ui.extensions.verticalScroll
|
||||
import net.codinux.banking.ui.forms.*
|
||||
import net.codinux.banking.ui.model.Config.NewLine
|
||||
|
||||
@Composable
|
||||
fun BankSettingsScreen(bank: BankAccessEntity, onClosed: () -> Unit) {
|
||||
|
||||
var enteredBankName by remember { mutableStateOf(bank.displayName) }
|
||||
|
||||
var enteredLoginName by remember { mutableStateOf(bank.loginName) }
|
||||
|
||||
var enteredPassword by remember { mutableStateOf(bank.password ?: "") }
|
||||
|
||||
var showDeleteBankAccessConfirmationDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val hasDataChanged by remember(enteredBankName, enteredLoginName, enteredPassword) {
|
||||
mutableStateOf(
|
||||
(enteredBankName != bank.bankName && (bank.userSetDisplayName == null || enteredBankName != bank.userSetDisplayName))
|
||||
|| (enteredLoginName != bank.loginName && enteredLoginName.isNotBlank())
|
||||
|| (enteredPassword != bank.password && enteredPassword.isNotBlank())
|
||||
)
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun saveChanges() {
|
||||
coroutineScope.launch {
|
||||
DI.bankingService.updateBank(bank, enteredLoginName, enteredPassword, enteredBankName.takeUnless { it.isBlank() })
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteBankAccessConfirmationDialog) {
|
||||
ConfirmDialog(
|
||||
title = "${bank.displayName} wirklich löschen?",
|
||||
text = "Dadurch werden auch alle zum Konto gehörenden Daten wie seine Kontoumsätze unwiderruflich gelöscht.${NewLine}Die Daten können nicht widerhergestellt werden.",
|
||||
onDismiss = { showDeleteBankAccessConfirmationDialog = false },
|
||||
onConfirm = {
|
||||
coroutineScope.launch {
|
||||
DI.bankingService.deleteBank(bank)
|
||||
}
|
||||
onClosed()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
FullscreenViewBase(
|
||||
bank.displayName,
|
||||
confirmButtonTitle = "Speichern",
|
||||
confirmButtonEnabled = hasDataChanged,
|
||||
showDismissButton = true,
|
||||
onConfirm = { saveChanges() },
|
||||
onClosed = onClosed
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll()) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
label = { Text("Name") },
|
||||
value = enteredBankName,
|
||||
onValueChange = { enteredBankName = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
SectionHeader("Online-Banking Zugangsdaten")
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("Login Name") },
|
||||
value = enteredLoginName,
|
||||
onValueChange = { enteredLoginName = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
PasswordTextField(
|
||||
password = enteredPassword,
|
||||
onChange = { enteredPassword = it }
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
SectionHeader("Konten")
|
||||
|
||||
Column(Modifier.padding(top = 8.dp)) {
|
||||
bank.accountsSorted.forEach { account ->
|
||||
FormListItem(account.displayName, itemHeight = 42.dp) {
|
||||
DI.uiState.showBankAccountSettingsScreenForAccount.value = account
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
SectionHeader("Bankdaten")
|
||||
|
||||
LabelledValue("Bankleitzahl", bank.domesticBankCode)
|
||||
|
||||
LabelledValue("BIC", bank.bic ?: "")
|
||||
|
||||
LabelledValue("Kontoinhaber", bank.customerName)
|
||||
|
||||
LabelledValue("FinTS Server", bank.serverAddress)
|
||||
}
|
||||
|
||||
Column {
|
||||
SectionHeader("TAN Verfahren")
|
||||
|
||||
Column(Modifier.padding(top = 8.dp)) {
|
||||
bank.tanMethodsSorted.forEach { tanMethod ->
|
||||
SelectableFormListItem(tanMethod.displayName, tanMethod == bank.selectedTanMethod, "TAN Verfahren ist ausgewähltes TAN Verfahren")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bank.tanMedia.isNotEmpty()) {
|
||||
Column {
|
||||
SectionHeader("TAN Medien")
|
||||
|
||||
Column(Modifier.padding(top = 8.dp)) {
|
||||
bank.tanMediaSorted.forEach { tanMedium ->
|
||||
SelectableFormListItem(tanMedium.displayName, tanMedium == bank.selectedTanMedium, "TAN Medium ist ausgewähltes TAN Medium")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Column(Modifier.padding(top = 24.dp, bottom = 18.dp)) {
|
||||
TextButton(modifier = Modifier.fillMaxWidth().height(50.dp), onClick = { showDeleteBankAccessConfirmationDialog = true }) {
|
||||
Text("Konto löschen", fontSize = 15.sp, color = Colors.DestructiveColor, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.ui.composables.BankIcon
|
||||
import net.codinux.banking.ui.composables.tan.ImageView
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.extensions.ImeNext
|
||||
import net.codinux.banking.ui.extensions.rememberVerticalScroll
|
||||
import net.codinux.banking.ui.forms.CaptionText
|
||||
import net.codinux.banking.ui.forms.OutlinedTextField
|
||||
import net.codinux.banking.ui.forms.Select
|
||||
import net.codinux.banking.ui.model.Config.NewLine
|
||||
import net.codinux.log.Log
|
||||
|
||||
private val epcQrCodeService = DI.epcQrCodeService
|
||||
|
||||
@Composable
|
||||
fun CreateEpcQrCodeScreen(onClosed: () -> Unit) {
|
||||
|
||||
val banks = DI.uiState.banks.collectAsState().value
|
||||
|
||||
val accountsWithIban: List<BankAccountEntity?> = buildList {
|
||||
add(null)
|
||||
addAll(DI.uiState.accounts.collectAsState().value.filter { it.iban != null })
|
||||
}
|
||||
|
||||
var selectedAccount by remember { mutableStateOf<BankAccountEntity?>(null) }
|
||||
|
||||
val bankOfSelectedAccount by remember(selectedAccount) {
|
||||
derivedStateOf { banks.firstOrNull { it.id == selectedAccount?.bankId } }
|
||||
}
|
||||
|
||||
val amountFocus = remember { FocusRequester() }
|
||||
|
||||
|
||||
var receiverName by remember { mutableStateOf("") }
|
||||
|
||||
var iban by remember { mutableStateOf("") }
|
||||
|
||||
var bic by remember { mutableStateOf("") }
|
||||
|
||||
var amount by remember { mutableStateOf("") }
|
||||
|
||||
var reference by remember { mutableStateOf("") }
|
||||
|
||||
var informationForUser by remember { mutableStateOf("") }
|
||||
|
||||
|
||||
var epcQrCodeGeneratingError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val epcQrCodeBytes by remember(receiverName, iban, bic, amount, reference, informationForUser) {
|
||||
derivedStateOf {
|
||||
epcQrCodeGeneratingError = null
|
||||
|
||||
if (receiverName.isNotBlank() && iban.isNotBlank()) {
|
||||
try {
|
||||
epcQrCodeService.generateEpcQrCode(receiverName, iban, bic.takeUnless { it.isBlank() }, amount.takeUnless { it.isBlank() }, reference.takeUnless { it.isBlank() }, informationForUser.takeUnless { it.isBlank() })
|
||||
} catch (e: Throwable) {
|
||||
Log.error(e) { "Could not generate EPC QR Code" }
|
||||
epcQrCodeGeneratingError = e.message
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
FullscreenViewBase("EPC QR Code erstellen", "Schließen", onClosed = onClosed) {
|
||||
Column(Modifier.fillMaxWidth().rememberVerticalScroll()) {
|
||||
if (epcQrCodeGeneratingError != null) {
|
||||
Text("QR Code konnte nicht erstellt werden:${NewLine}$epcQrCodeGeneratingError", color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
|
||||
} else if (epcQrCodeBytes == null) {
|
||||
Text("Mit EPC QR Codes, welche als GiroCode, scan2code, ... vermarktet werden, können Überweisungsdaten ganz einfach von Banking Apps eingelesen werden.")
|
||||
Text("Hier können Sie Ihren eigenen erstellen, so dass jemand Ihre Überweisungsdaten einlesen und Ihnen ganz schnell Geld überweisen kann.")
|
||||
} else {
|
||||
ImageView(epcQrCodeBytes!!, "EpcQrCode", "Erzeugter EPC QR Code", 350, 100, 700)
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.Center) {
|
||||
Text("Scannen Sie diesen Code auf einem anderen Gerät mit einer Banking App, z. B. Bankmeister", textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (accountsWithIban.size > 1) {
|
||||
Select(
|
||||
"Für Konto",
|
||||
accountsWithIban,
|
||||
selectedAccount,
|
||||
{ account ->
|
||||
selectedAccount = account
|
||||
|
||||
if (account != null) {
|
||||
iban = account.iban ?: ""
|
||||
bic = banks.firstOrNull { it.id == selectedAccount?.bankId }?.bic ?: ""
|
||||
receiverName = account.accountHolderName
|
||||
|
||||
amountFocus.requestFocus()
|
||||
}
|
||||
},
|
||||
{ account -> account?.displayName ?: "" },
|
||||
leadingIcon = bankOfSelectedAccount?.let { { BankIcon(bankOfSelectedAccount) } },
|
||||
dropDownItemContent = { account ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BankIcon(banks.firstOrNull { it.id == account?.bankId }, Modifier.padding(end = 6.dp))
|
||||
|
||||
Text(account?.displayName ?: "")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("Empfänger*in") },
|
||||
value = receiverName,
|
||||
onValueChange = { receiverName = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
keyboardOptions = KeyboardOptions.ImeNext
|
||||
)
|
||||
|
||||
CaptionText("${receiverName.length} / 70")
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("IBAN") },
|
||||
value = iban,
|
||||
onValueChange = { iban = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
keyboardOptions = KeyboardOptions.ImeNext
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("BIC (optional)") },
|
||||
value = bic,
|
||||
onValueChange = { bic = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
keyboardOptions = KeyboardOptions.ImeNext
|
||||
)
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text("Betrag (optional)") },
|
||||
value = amount,
|
||||
onValueChange = { amount = it },
|
||||
modifier = Modifier.weight(1f).padding(vertical = 8.dp).focusRequester(amountFocus),
|
||||
keyboardOptions = KeyboardOptions.ImeNext
|
||||
)
|
||||
|
||||
Text(DI.formatUtil.formatCurrency("EUR"), Modifier.padding(start = 4.dp)) // Euro is currently the only supported currency
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text("Verwendungszweck (optional)") },
|
||||
value = reference,
|
||||
onValueChange = { reference = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
keyboardOptions = KeyboardOptions.ImeNext
|
||||
)
|
||||
|
||||
CaptionText("${reference.length} / 140")
|
||||
|
||||
// not exposing it to user as it's a) not displayed by most apps and b) may causes overflow of used QR code library
|
||||
// OutlinedTextField(
|
||||
// label = { Text("Information für den Nutzer (optional)") },
|
||||
// value = informationForUser,
|
||||
// onValueChange = { informationForUser = it },
|
||||
// modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
// keyboardOptions = KeyboardOptions.ImeNext
|
||||
// )
|
||||
//
|
||||
// CaptionText("${informationForUser.length} / 70")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.Text
|
||||
|
@ -14,12 +13,18 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
|
||||
import net.codinux.banking.persistence.entities.AccountTransactionEntity
|
||||
import net.codinux.banking.ui.IOorDefault
|
||||
import net.codinux.banking.ui.PlatformType
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.extensions.horizontalScroll
|
||||
import net.codinux.banking.ui.extensions.verticalScroll
|
||||
import net.codinux.banking.ui.service.BankDataImporterAndExporter
|
||||
|
||||
|
||||
private const val iOSMaxDisplayedDataLength = 20_000
|
||||
|
||||
@Composable
|
||||
fun ExportScreen(onClosed: () -> Unit) {
|
||||
var transactions: Collection<AccountTransactionEntity>
|
||||
|
@ -28,6 +33,8 @@ fun ExportScreen(onClosed: () -> Unit) {
|
|||
|
||||
var exportedDataText by remember { mutableStateOf("") }
|
||||
|
||||
var exportedDataTextToDisplay by remember { mutableStateOf("") }
|
||||
|
||||
val importerExporter = BankDataImporterAndExporter()
|
||||
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
@ -41,12 +48,15 @@ fun ExportScreen(onClosed: () -> Unit) {
|
|||
|
||||
withContext(Dispatchers.Main) {
|
||||
exportedDataText = initiallyExportedData
|
||||
exportedDataTextToDisplay = if (DI.platform.type == PlatformType.iOS && exportedDataText.length > iOSMaxDisplayedDataLength) exportedDataText.substring(0,
|
||||
iOSMaxDisplayedDataLength) + "\r\n...\r\n(Wir mussten die Anzeige abschneiden, da iOS mit längeren Zeichenketten nicht klar kommt (iOS only bug)"
|
||||
else exportedDataText
|
||||
isLoadingExportedData = false
|
||||
}
|
||||
}
|
||||
|
||||
FullscreenViewBase("Umsätze exportieren", onClosed = onClosed) {
|
||||
Column {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text("Es gibt leider noch keinen \"Datei auswählen Dialog\", ist sehr schwierig plattformübergreifend umzusetzen, deshalb bitte folgenden Text kopieren und in eine Textdatei einfügen:")
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
|
@ -55,13 +65,7 @@ fun ExportScreen(onClosed: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
if (isLoadingExportedData == false) {
|
||||
Column(Modifier.verticalScroll(ScrollState(0), enabled = true).horizontalScroll(ScrollState(0), enabled = true)) {
|
||||
SelectionContainer {
|
||||
Text(exportedDataText, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isLoadingExportedData) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
|
@ -71,6 +75,12 @@ fun ExportScreen(onClosed: () -> Unit) {
|
|||
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.verticalScroll().horizontalScroll()) {
|
||||
SelectionContainer(modifier = Modifier.fillMaxSize()) {
|
||||
Text(exportedDataTextToDisplay, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.client.model.BankAccount
|
||||
import net.codinux.banking.persistence.entities.BankAccountEntity
|
||||
import net.codinux.banking.ui.composables.BankIcon
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.extensions.horizontalScroll
|
||||
import net.codinux.banking.ui.extensions.verticalScroll
|
||||
import net.codinux.banking.ui.forms.SectionHeader
|
||||
import net.codinux.banking.ui.forms.Select
|
||||
import net.codinux.banking.ui.model.Config.NewLine
|
||||
|
||||
|
||||
@Composable
|
||||
fun FeedbackScreen(onClosed: () -> Unit) {
|
||||
|
||||
val banks = DI.uiState.banks.collectAsState().value
|
||||
|
||||
val messageLog by remember { mutableStateOf(DI.bankingService.getMessageLog()) }
|
||||
|
||||
val accountsWithMessageLog: List<BankAccount?> by remember(messageLog) { derivedStateOf {
|
||||
listOf<BankAccount?>(null) + messageLog.mapNotNull { it.account }.toSet().toList()
|
||||
} }
|
||||
|
||||
var selectedAccount by remember { mutableStateOf<BankAccount?>(null) }
|
||||
|
||||
val bankOfSelectedAccount by remember(selectedAccount) {
|
||||
// TODO: MessageLogEntries of added accounts contain a BankAccount instead of a BankAccountEntity object
|
||||
derivedStateOf { banks.firstOrNull { it.id == (selectedAccount as? BankAccountEntity)?.bankId } }
|
||||
}
|
||||
|
||||
val displayedLogEntries by remember(messageLog, selectedAccount) { derivedStateOf {
|
||||
messageLog.filter { selectedAccount == null || it.account == selectedAccount }.sortedBy { it.messageNumber }
|
||||
} }
|
||||
|
||||
val displayedLogEntriesText by remember(displayedLogEntries) {
|
||||
derivedStateOf { displayedLogEntries.map {
|
||||
"${it.messageNumberString} ${it.bank?.displayName ?: ""} ${it.account?.displayName ?: ""} ${it.type} ${it.jobType} ${it.messageCategory}${NewLine}${it.message}"
|
||||
}.joinToString(NewLine + NewLine) }
|
||||
}
|
||||
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
|
||||
FullscreenViewBase("Feedback", onClosed = onClosed) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text("Man kann uns direkt aus der App heraus noch kein Feedback schicken (kommt noch), aber schon mal das Nachrichten-Protokoll, das ihr bei Fehlern an die (unfähgigen) Entwickler schicken könnt:", modifier = Modifier.padding(horizontal = 24.dp).padding(top = 8.dp), textAlign = TextAlign.Center)
|
||||
|
||||
SectionHeader("Nachrichten-Protokoll:") // TODO: add a Switch "Add message protocol"
|
||||
|
||||
if (messageLog.isEmpty()) {
|
||||
Row(Modifier.fillMaxSize().padding(24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
||||
Text("Sie haben noch keine Aktion wie das Abrufen der Kontoumsätze ausgeführt, deshalb gibt es noch kein Nachrichtenprotokoll zu diesen Aktionen", textAlign = TextAlign.Center)
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Select(
|
||||
"Konto",
|
||||
accountsWithMessageLog,
|
||||
selectedAccount,
|
||||
{ account -> selectedAccount = account },
|
||||
{ account -> account?.displayName ?: "Alle Konten" },
|
||||
Modifier.weight(0.9f),
|
||||
leadingIcon = bankOfSelectedAccount?.let { { BankIcon(bankOfSelectedAccount) } },
|
||||
dropDownItemContent = { account ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BankIcon(banks.firstOrNull { it.id == (account as? BankAccountEntity)?.bankId }, Modifier.padding(end = 6.dp))
|
||||
|
||||
Text(account?.displayName ?: "")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(0.1f))
|
||||
|
||||
TextButton({ clipboardManager.setText(AnnotatedString(displayedLogEntriesText))}) {
|
||||
Text("Kopieren", color = Colors.CodinuxSecondaryColor)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.verticalScroll().horizontalScroll().padding(top = 8.dp)) {
|
||||
SelectionContainer(modifier = Modifier.fillMaxSize()) {
|
||||
Text(displayedLogEntriesText, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -3,9 +3,8 @@ package net.codinux.banking.ui.screens
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
@ -13,15 +12,23 @@ 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.PlatformType
|
||||
import net.codinux.banking.ui.composables.CloseButton
|
||||
import net.codinux.banking.ui.composables.text.HeaderText
|
||||
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.extensions.applyPlatformSpecificPadding
|
||||
|
||||
@Composable
|
||||
fun FullscreenViewBase(
|
||||
title: String,
|
||||
confirmButtonTitle: String = "OK",
|
||||
confirmButtonEnabled: Boolean = true,
|
||||
dismissButtonTitle: String = "Abbrechen",
|
||||
showDismissButton: Boolean = false,
|
||||
showButtonBar: Boolean = true,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onClosed: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
|
@ -29,15 +36,13 @@ fun FullscreenViewBase(
|
|||
onClosed,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).padding(8.dp)) {
|
||||
Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).applyPlatformSpecificPadding().padding(horizontal = 12.dp)) {
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f))
|
||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp).height(32.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
HeaderText(title, Modifier.weight(1f), textColor = Style.ListItemHeaderTextColor)
|
||||
|
||||
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.type != PlatformType.Android) { // for iOS it's also relevant due to the missing back gesture / back button
|
||||
CloseButton(onClick = onClosed)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,17 +50,20 @@ fun FullscreenViewBase(
|
|||
content()
|
||||
}
|
||||
|
||||
if (showButtonBar) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
// TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
|
||||
// Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
|
||||
// }
|
||||
//
|
||||
// Spacer(Modifier.width(8.dp))
|
||||
if (showDismissButton) {
|
||||
TextButton(onClick = onClosed, Modifier.weight(1f)) {
|
||||
Text(dismissButtonTitle, color = Colors.CodinuxSecondaryColor)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = confirmButtonEnabled,
|
||||
onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
|
||||
onClick = { onConfirm?.invoke(); onClosed() }
|
||||
) {
|
||||
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
|
||||
}
|
||||
|
@ -63,3 +71,4 @@ fun FullscreenViewBase(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue