Compare commits

..

No commits in common. "develop" and "main" have entirely different histories.

162 changed files with 674 additions and 4598 deletions

3
.gitignore vendored
View File

@ -21,8 +21,5 @@ xcuserdata
!*.xcodeproj/project.xcworkspace/ !*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata !*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
**/*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
composeApp/release/
composeApp/data/ composeApp/data/
BankingPersistence/data/

View File

@ -1,114 +0,0 @@
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
}
}

View File

@ -1,7 +0,0 @@
package net.codinux.banking.persistence
import android.content.Context
object AndroidContext {
lateinit var applicationContext: Context
}

View File

@ -1,10 +0,0 @@
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)

View File

@ -1,69 +0,0 @@
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?)
}

View File

@ -1,17 +0,0 @@
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
)

View File

@ -1,11 +0,0 @@
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 ""}"
}

View File

@ -1,14 +0,0 @@
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;

View File

@ -1,10 +0,0 @@
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)

View File

@ -1,9 +0,0 @@
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")
}

View File

@ -1,79 +0,0 @@
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
}
}

View File

@ -1,7 +1,7 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat 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.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget 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 import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins { plugins {
@ -11,23 +11,18 @@ plugins {
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.sqldelight)
} }
kotlin { 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 { js {
moduleName = "Bankmeister" moduleName = "composeApp"
browser { browser {
val projectDirPath = project.projectDir.path val projectDirPath = project.projectDir.path
commonWebpackConfig { commonWebpackConfig {
outputFileName = "Bankmeister.js" outputFileName = "composeApp.js"
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
static = (static ?: mutableListOf()).apply { static = (static ?: mutableListOf()).apply {
// Serve sources to debug inside browser // Serve sources to debug inside browser
@ -55,36 +50,26 @@ kotlin {
iosSimulatorArm64() iosSimulatorArm64()
).forEach { iosTarget -> ).forEach { iosTarget ->
iosTarget.binaries.framework { iosTarget.binaries.framework {
baseName = "BankmeisterFramework" baseName = "ComposeApp"
isStatic = false isStatic = true
}
// 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 { sourceSets {
val desktopMain by getting val desktopMain by getting
commonMain.dependencies { commonMain.dependencies {
implementation(project(":BankingPersistence"))
implementation(libs.banking.client.model) implementation(libs.banking.client.model)
implementation(libs.fints4k.banking.client) implementation(libs.fints4k.banking.client)
implementation(libs.bank.finder)
implementation(libs.epcqrcode)
implementation(libs.kcsv) implementation(libs.kcsv)
implementation(libs.klf) implementation(libs.klf)
implementation(libs.kotlinx.serializable) implementation(libs.kotlinx.serializable)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines.extensions)
implementation(libs.sqldelight.paging.extensions)
// UI // UI
implementation(compose.runtime) implementation(compose.runtime)
implementation(compose.foundation) implementation(compose.foundation)
@ -107,20 +92,12 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(compose.preview) implementation(compose.preview)
implementation(libs.androidx.activity.compose) 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.favre.bcrypt) implementation(libs.sqldelight.android.driver)
// for reading EPC QR Codes from camera
implementation(libs.zxing.core)
implementation(libs.camerax.camera2)
implementation(libs.camerax.view)
implementation(libs.camerax.lifecycle)
} }
iosMain.dependencies { nativeMain.dependencies {
implementation(libs.sqldelight.native.driver)
} }
jvmTest.dependencies { jvmTest.dependencies {
@ -131,10 +108,21 @@ kotlin {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.swing)
implementation(libs.favre.bcrypt) implementation(libs.sqldelight.sqlite.driver)
implementation(libs.logback) implementation(libs.logback)
implementation(libs.janino) }
}
}
sqldelight {
databases {
create("BankmeisterDb") {
packageName.set("net.codinux.banking.dataaccess")
generateAsync = true
schemaOutputDirectory = file("src/commonMain/sqldelight/databases")
} }
} }
} }
@ -152,8 +140,8 @@ android {
applicationId = "net.codinux.banking.android" // the appId of the old Bankmeister app to be able to use the old PlayStore entry 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() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 21 versionCode = 10
versionName = "1.0.0-Alpha-15" versionName = "1.0.0-Alpha-12"
} }
packaging { packaging {
resources { resources {
@ -201,34 +189,11 @@ compose.desktop {
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "net.codinux.banking.ui"
modules("java.sql", "java.naming") // java.naming is required by logback packageVersion = "1.0.0"
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" description = "Datenschutzfreundliche Multi-Banking App für die meisten deutschen Banken"
copyright = "© 2024 codinux GmbH & Co.KG. All rights reserved." copyright = "© 2024 codinux GmbH & Co.KG. All rights reserved."
vendor = "codinux GmbH & Co.KG" 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 { buildTypes.release.proguard {
@ -237,58 +202,3 @@ 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()
}
}
}

View File

@ -2,10 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <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 <application
android:allowBackup="true" android:allowBackup="true"

View File

@ -1,55 +1,30 @@
package net.codinux.banking.ui package net.codinux.banking.ui
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.fragment.app.FragmentActivity import app.cash.sqldelight.async.coroutines.synchronous
import net.codinux.banking.persistence.AndroidContext import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import net.codinux.banking.ui.service.AuthenticationService import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.service.BiometricAuthenticationService import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.service.ImageService
class MainActivity : FragmentActivity() {
private val request = ActivityResultContracts.RequestMultiplePermissions()
private val activityResultLauncher = registerForActivityResult(request) { }
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
AndroidContext.applicationContext = this.applicationContext ImageService.context = this.applicationContext
AuthenticationService.biometricAuthenticationService = BiometricAuthenticationService(this) DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))
setContent { setContent {
App() 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 @Preview
@Composable @Composable
fun AppAndroidPreview() { fun AppAndroidPreview() {

View File

@ -1,10 +1,8 @@
package net.codinux.banking.ui package net.codinux.banking.ui
import android.os.Build import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -13,18 +11,6 @@ import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IOorDefault: CoroutineDispatcher actual val Dispatchers.IOorDefault: CoroutineDispatcher
get() = Dispatchers.IO 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 @Composable
actual fun rememberScreenSizeInfo(): ScreenSizeInfo { actual fun rememberScreenSizeInfo(): ScreenSizeInfo {
val config = LocalConfiguration.current val config = LocalConfiguration.current

View File

@ -10,8 +10,8 @@ import net.codinux.banking.ui.forms.RoundedCornersCard
@Preview @Preview
@Composable @Composable
fun HoldingListItemPreview() { fun HoldingListItemPreview() {
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 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.0, "EUR", Amount("21455.36"), Amount("100.18"), 8.8f, Amount("19872.04"), Amount("92.04")) val holding2 = Holding("NVIDIA Corp.", null, null, 214, "EUR", Amount("21455.36"), Amount("100.18"), 8.8f, Amount("19872.04"), Amount("92.04"))
RoundedCornersCard { RoundedCornersCard {
Column { Column {

View File

@ -29,19 +29,6 @@ fun EnterTanDialogPreview_TanImage() {
val bank = BankViewInfo("10010010", "Ihr krasser Login Name", "Phantasie Bank", BankingGroup.Comdirect) 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) val tanChallenge = TanChallenge(TanChallengeType.Image, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethod.identifier, listOf(tanMethod), null, emptyList(), tanImage, null, bank)
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { } EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
@ -54,7 +41,7 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall
val tanImage = TanImage("image/png", tanImageBytes) val tanImage = TanImage("image/png", tanImageBytes)
val tanMethods = listOf( 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) TanMethod("chipTAN-QR", TanMethodType.ChipTanQrCode, "913", 6, AllowedTanFormat.Numeric)
) )
@ -73,20 +60,10 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall
@Preview @Preview
@Composable @Composable
fun EnterTanDialogPreview_FlickerCode() { fun EnterTanDialogPreview_Flickercode() {
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickerCode, "902")) val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickercode, "902"))
val bank = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank) val bank = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank)
val tanChallenge = TanChallenge(TanChallengeType.FlickerCode, ActionRequiringTan.GetTanMedia, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("100880077104", "0604800771040F")) val tanChallenge = TanChallenge(TanChallengeType.Flickercode, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("", ""))
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) { }) { } EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
} }

View File

@ -1,24 +0,0 @@
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 = { }
)
}

View File

@ -1,15 +0,0 @@
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) { }
}

View File

@ -1,29 +0,0 @@
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"))
}
}
}

View File

@ -1,66 +0,0 @@
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))
}
}
}

View File

@ -1,16 +1,22 @@
package net.codinux.banking.ui.service package net.codinux.banking.ui.service
import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import net.codinux.banking.persistence.AndroidContext
import net.codinux.log.Log import net.codinux.log.Log
import java.io.File import java.io.File
import java.net.URL import java.net.URL
import java.security.MessageDigest import java.security.MessageDigest
private val cacheDir by lazy { File(AndroidContext.applicationContext.cacheDir, "imageCache").also { it.mkdirs() } } object ImageService {
lateinit var context: Context
}
private val cacheDir by lazy { File(ImageService.context.cacheDir, "imageCache").also { it.mkdirs() } }
private val messageDigest = MessageDigest.getInstance("SHA-256") private val messageDigest = MessageDigest.getInstance("SHA-256")

View File

@ -1,13 +0,0 @@
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
}
}

View File

@ -1,186 +0,0 @@
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)
}
}

View File

@ -1,5 +1,3 @@
<resources> <resources>
<string name="app_name">Bankmeister</string> <string name="app_name">Bankmeister</string>
<string name="activity_login_authenticate_with_biometrics_prompt">Authentifizieren Sich sich um die App zu entsperren</string>
</resources> </resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,47 @@
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?
}

View File

@ -1,20 +1,15 @@
package net.codinux.banking.persistence package net.codinux.banking.dataaccess
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction 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.BankAccess
import net.codinux.banking.client.model.securitiesaccount.Holding import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.persistence.entities.AccountTransactionEntity import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.persistence.entities.BankAccountEntity import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.HoldingEntity import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.persistence.entities.BankAccessEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.persistence.entities.UiSettingsEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.settings.AppSettings import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.model.settings.ImageSettings import net.codinux.banking.ui.settings.UiSettings
import net.codinux.banking.ui.model.settings.TransactionsGrouping
class InMemoryBankingRepository( class InMemoryBankingRepository(
banks: Collection<BankAccess> = emptyList(), banks: Collection<BankAccess> = emptyList(),
@ -28,9 +23,7 @@ class InMemoryBankingRepository(
private val transactions = transactions.map { map(it) }.toMutableList() private val transactions = transactions.map { map(it) }.toMutableList()
private var uiSettings: UiSettingsEntity = UiSettingsEntity(true, TransactionsGrouping.Month, true, true, true) private lateinit var uiSettings: UiSettings
private var imageSettings = mutableMapOf<String, ImageSettings>()
override fun getAppSettings(): AppSettings? = appSettings override fun getAppSettings(): AppSettings? = appSettings
@ -39,17 +32,12 @@ class InMemoryBankingRepository(
this.appSettings = settings this.appSettings = settings
} }
override fun getUiSettings() = this.uiSettings override fun getUiSettings(settings: UiSettings) {
override suspend fun saveUiSettings(settings: UiSettingsEntity) {
this.uiSettings = settings this.uiSettings = settings
} }
override suspend fun saveUiSettings(settings: UiSettings) {
override fun getImageSettings(id: String) = imageSettings[id] this.uiSettings = settings
override suspend fun saveImageSettings(settings: ImageSettings) {
imageSettings[settings.id] = settings
} }
@ -61,27 +49,6 @@ class InMemoryBankingRepository(
return entity 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> { override suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity> {
throw NotImplementedError("Lazy developer, method is not implemented") throw NotImplementedError("Lazy developer, method is not implemented")
} }
@ -109,10 +76,6 @@ class InMemoryBankingRepository(
override fun getTransactionById(transactionId: Long): AccountTransactionEntity? = override fun getTransactionById(transactionId: Long): AccountTransactionEntity? =
getAllAccountTransactions().firstOrNull { it.id == transactionId } 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( private fun map(bank: BankAccess) = BankAccessEntity(
nextId++, nextId++,

View File

@ -1,53 +1,25 @@
package net.codinux.banking.persistence package net.codinux.banking.dataaccess
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction import net.codinux.banking.client.model.*
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.securitiesaccount.Holding
import net.codinux.banking.client.model.tan.AllowedTanFormat import net.codinux.banking.client.model.tan.*
import net.codinux.banking.client.model.tan.MobilePhoneTanMedium import net.codinux.banking.dataaccess.entities.*
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.AccountTransactionViewModel
import net.codinux.banking.ui.model.settings.* import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.model.settings.ImageSettings import net.codinux.banking.ui.settings.UiSettings
import net.codinux.log.logger import net.codinux.log.logger
import kotlin.enums.EnumEntries import kotlin.enums.EnumEntries
import kotlin.js.JsName import kotlin.js.JsName
import kotlin.jvm.JvmName import kotlin.jvm.JvmName
open class SqliteBankingRepository(
expect fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver sqlDriver: SqlDriver
) : BankingRepository {
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) private val database = BankmeisterDb(sqlDriver)
@ -80,30 +52,18 @@ open class SqliteBankingRepository : BankingRepository {
} }
override fun getUiSettings(): UiSettingsEntity? { override fun getUiSettings(settings: UiSettings) {
return settingsQueries.getUiSettings { _, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors -> settingsQueries.getUiSettings { _, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors ->
UiSettingsEntity( settings.transactionsGrouping.value = mapToEnum(transactionsGrouping, TransactionsGrouping.entries)
showBalance, settings.showBalance.value = showBalance
mapToEnum(transactionsGrouping, TransactionsGrouping.entries), settings.showBankIcons.value = showBankIcons
showTransactionsInAlternatingColors, settings.showColoredAmounts.value = showColoredAmounts
showBankIcons, settings.showTransactionsInAlternatingColors.value = showTransactionsInAlternatingColors
showColoredAmounts
)
}.executeAsOneOrNull() }.executeAsOneOrNull()
} }
override suspend fun saveUiSettings(settings: UiSettingsEntity) { override suspend fun saveUiSettings(settings: UiSettings) {
settingsQueries.upsertUiSettings(mapEnum(settings.transactionsGrouping), settings.showBalance, settings.showBankIcons, settings.showColoredAmounts, settings.showTransactionsInAlternatingColors) settingsQueries.upsertUiSettings(mapEnum(settings.transactionsGrouping.value), settings.showBalance.value, settings.showBankIcons.value, settings.showColoredAmounts.value, settings.showTransactionsInAlternatingColors.value)
}
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))
} }
@ -113,9 +73,9 @@ open class SqliteBankingRepository : BankingRepository {
val tanMedia = getAllTanMedia().groupBy { it.bankId }.mapValues { it.value.toMutableList() } val tanMedia = getAllTanMedia().groupBy { it.bankId }.mapValues { it.value.toMutableList() }
val holdings = getAllHoldings().groupBy { it.accountId } val holdings = getAllHoldings().groupBy { it.accountId }
return bankQueries.getAllBanks { id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, selectedTanMethodIdentifier, selectedTanMediumIdentifier, bankingGroup, serverAddress, countryCode, serializedClientData, userSetDisplayName, displayIndex, iconUrl, wrongCredentialsEntered -> return bankQueries.getAllBanks { id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, selectedTanMethodIdentifier, selectedTanMediumIdentifier, bankingGroup, serverAddress, countryCode, userSetDisplayName, clientData, displayIndex, iconUrl, wrongCredentialsEntered ->
BankAccessEntity(id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, getAccountsOfBank(id, bankAccounts, holdings), selectedTanMethodIdentifier, tanMethods[id] ?: mutableListOf(), selectedTanMediumIdentifier, tanMedia[id] ?: mutableListOf(), 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, null, serializedClientData) bankingGroup?.let { BankingGroup.valueOf(it) }, serverAddress, countryCode, userSetDisplayName, displayIndex.toInt(), iconUrl, wrongCredentialsEntered)
}.executeAsList() }.executeAsList()
} }
@ -129,7 +89,7 @@ open class SqliteBankingRepository : BankingRepository {
return bankQueries.transactionWithResult { return bankQueries.transactionWithResult {
bankQueries.insertBank(bank.domesticBankCode, bank.loginName, bank.password, bank.bankName, bank.bic, bankQueries.insertBank(bank.domesticBankCode, bank.loginName, bank.password, bank.bankName, bank.bic,
bank.customerName, bank.userId, bank.selectedTanMethodIdentifier, bank.selectedTanMediumIdentifier, bank.customerName, bank.userId, bank.selectedTanMethodIdentifier, bank.selectedTanMediumIdentifier,
bank.bankingGroup?.name, bank.serverAddress, bank.countryCode, bank.serializedClientData, bank.userSetDisplayName, bank.displayIndex.toLong(), bank.iconUrl, bank.wrongCredentialsEntered bank.bankingGroup?.name, bank.serverAddress, bank.countryCode, null, 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 val bankId = getLastInsertedId() // getLastInsertedId() / last_insert_rowid() has to be called in a transaction with the insert operation, otherwise it will not work
@ -143,58 +103,6 @@ open class SqliteBankingRepository : BankingRepository {
} }
} }
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 -> 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( BankAccountEntity(
@ -259,7 +167,7 @@ open class SqliteBankingRepository : BankingRepository {
bankId, bankId,
displayName, displayName,
mapToEnum(type, TanMethodType.entries, FinTs4kMapper.TanMethodTypesToMigrate), mapToEnum(type, TanMethodType.entries),
identifier, identifier,
mapToInt(maxTanInputLength), mapToInt(maxTanInputLength),
mapToEnum(allowedTanFormat, AllowedTanFormat.entries), mapToEnum(allowedTanFormat, AllowedTanFormat.entries),
@ -349,7 +257,7 @@ open class SqliteBankingRepository : BankingRepository {
protected open fun getAllHoldings(): List<HoldingEntity> = protected open fun getAllHoldings(): List<HoldingEntity> =
accountTransactionQueries.selectAllHoldings { id, bankId, accountId, name, isin, wkn, quantity, currency, totalBalance, marketValue, performancePercentage, totalCostPrice, averageCostPrice, pricingTime, buyingDate -> accountTransactionQueries.selectAllHoldings { id, bankId, accountId, name, isin, wkn, quantity, currency, totalBalance, marketValue, performancePercentage, totalCostPrice, averageCostPrice, pricingTime, buyingDate ->
HoldingEntity(id, bankId, accountId, name, isin, wkn, quantity, currency, mapToAmount(totalBalance), mapToAmount(marketValue), performancePercentage?.toFloat(), mapToAmount(totalCostPrice), mapToAmount(averageCostPrice), mapToInstant(pricingTime), mapToDate(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))
}.executeAsList() }.executeAsList()
override suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity> = override suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity> =
@ -366,7 +274,7 @@ open class SqliteBankingRepository : BankingRepository {
holding.name, holding.isin, holding.wkn, holding.name, holding.isin, holding.wkn,
holding.quantity, holding.currency, mapInt(holding.quantity), holding.currency,
mapAmount(holding.totalBalance), mapAmount(holding.marketValue), mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
holding.performancePercentage?.toDouble(), holding.performancePercentage?.toDouble(),
@ -383,7 +291,7 @@ open class SqliteBankingRepository : BankingRepository {
holdings.onEach { holding -> holdings.onEach { holding ->
accountTransactionQueries.updateHolding( accountTransactionQueries.updateHolding(
holding.name, holding.isin, holding.wkn, holding.name, holding.isin, holding.wkn,
holding.quantity, holding.currency, mapInt(holding.quantity), holding.currency,
mapAmount(holding.totalBalance), mapAmount(holding.marketValue), mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
holding.performancePercentage?.toDouble(), holding.performancePercentage?.toDouble(),
@ -407,8 +315,8 @@ open class SqliteBankingRepository : BankingRepository {
override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> = override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
accountTransactionQueries.getAllTransactionsAsViewModel { id, bankId, accountId, amount, currency, reference, valueDate, otherPartyName, postingText, userSetReference, userSetOtherPartyName -> 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, userSetReference, userSetOtherPartyName) AccountTransactionViewModel(id, bankId, accountId, mapToAmount(amount), currency, reference, mapToDate(valueDate), otherPartyName, postingText, userSetDisplayName, userSetOtherPartyName)
}.executeAsList() }.executeAsList()
override fun getAllAccountTransactions(): List<AccountTransactionEntity> { override fun getAllAccountTransactions(): List<AccountTransactionEntity> {
@ -471,22 +379,6 @@ open class SqliteBankingRepository : BankingRepository {
return AccountTransactionEntity(getLastInsertedId(), bankId, accountId, transaction) 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 = private fun getLastInsertedId(): Long =
bankQueries.getLastInsertedId().executeAsOne() bankQueries.getLastInsertedId().executeAsOne()
@ -605,16 +497,7 @@ open class SqliteBankingRepository : BankingRepository {
private fun <E : Enum<E>> mapEnum(enum: Enum<E>): String = enum.name private fun <E : Enum<E>> mapEnum(enum: Enum<E>): String = enum.name
private fun <E : Enum<E>> mapToEnum(enumName: String, values: EnumEntries<E>): E = private fun <E : Enum<E>> mapToEnum(enumName: String, values: EnumEntries<E>): E =
try {
values.first { it.name == enumName } 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? { private fun <E : Enum<E>> mapToEnumNullable(enumName: String, values: EnumEntries<E>): E? {
val mapped = values.firstOrNull { it.name == enumName } val mapped = values.firstOrNull { it.name == enumName }

View File

@ -1,4 +1,4 @@
package net.codinux.banking.persistence.entities package net.codinux.banking.dataaccess.entities
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction import net.codinux.banking.client.model.AccountTransaction
@ -116,4 +116,9 @@ class AccountTransactionEntity(
transaction.isReversal, transaction.isReversal,
) )
override val identifier: String by lazy {
"$bankId ${super.identifier}"
}
} }

View File

@ -1,7 +1,8 @@
package net.codinux.banking.persistence.entities package net.codinux.banking.dataaccess.entities
import net.codinux.banking.client.model.BankAccess import net.codinux.banking.client.model.BankAccess
import net.codinux.banking.client.model.BankingGroup import net.codinux.banking.client.model.BankingGroup
import net.codinux.banking.client.model.tan.TanMedium
class BankAccessEntity( class BankAccessEntity(
val id: Long, val id: Long,
@ -32,10 +33,7 @@ class BankAccessEntity(
displayIndex: Int = 0, displayIndex: Int = 0,
iconUrl: String? = null, 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) { ) : BankAccess(domesticBankCode, loginName, password, bankName, bic, customerName, userId, accounts, selectedTanMethodIdentifier, tanMethods, selectedTanMediumIdentifier, tanMedia, bankingGroup, serverAddress, countryCode) {
init { init {
@ -44,9 +42,6 @@ class BankAccessEntity(
this.iconUrl = iconUrl this.iconUrl = iconUrl
this.wrongCredentialsEntered = wrongCredentialsEntered this.wrongCredentialsEntered = wrongCredentialsEntered
this.clientData = clientData
this.serializedClientData = serializedClientData
} }
@ -60,14 +55,4 @@ class BankAccessEntity(
bank.iconUrl, bank.wrongCredentialsEntered, 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 }
} }

View File

@ -1,4 +1,4 @@
package net.codinux.banking.persistence.entities package net.codinux.banking.dataaccess.entities
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate

View File

@ -1,4 +1,4 @@
package net.codinux.banking.persistence.entities package net.codinux.banking.dataaccess.entities
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@ -15,7 +15,7 @@ class HoldingEntity(
isin: String? = null, isin: String? = null,
wkn: String? = null, wkn: String? = null,
quantity: Double? = null, quantity: Int? = null,
currency: String? = null, currency: String? = null,
totalBalance: Amount? = null, totalBalance: Amount? = null,

View File

@ -1,4 +1,4 @@
package net.codinux.banking.persistence.entities package net.codinux.banking.dataaccess.entities
import net.codinux.banking.client.model.tan.* import net.codinux.banking.client.model.tan.*

View File

@ -1,4 +1,4 @@
package net.codinux.banking.persistence.entities package net.codinux.banking.dataaccess.entities
import net.codinux.banking.client.model.tan.AllowedTanFormat import net.codinux.banking.client.model.tan.AllowedTanFormat
import net.codinux.banking.client.model.tan.TanMethod import net.codinux.banking.client.model.tan.TanMethod

View File

@ -7,14 +7,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch 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.Colors
import net.codinux.banking.ui.config.DI 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.banking.ui.screens.MainScreen
import net.codinux.log.Log
import net.codinux.log.LoggerFactory import net.codinux.log.LoggerFactory
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@ -24,40 +19,20 @@ private val typography = Typography(
@Composable @Composable
@Preview @Preview
fun App(repository: BankingRepository? = null) { fun App() {
LoggerFactory.defaultLoggerName = "net.codinux.banking.ui.Bankmeister" LoggerFactory.defaultLoggerName = "net.codinux.banking.ui.Bankmeister"
val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, onPrimary = Color.White, val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, onPrimary = Color.White,
secondary = Colors.Accent, secondaryVariant = Colors.Accent, onSecondary = Color.White) secondary = Colors.Accent, secondaryVariant = Colors.Accent, onSecondary = Color.White)
var isInitialized by remember { mutableStateOf(false) } 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() val coroutineScope = rememberCoroutineScope()
MaterialTheme(colors = colors, typography = typography) { MaterialTheme(colors = colors, typography = typography) {
if (isLoggedIn == false) {
LoginScreen(appSettings) {
isLoggedIn = true
}
} else {
MainScreen() MainScreen()
} }
}
LaunchedEffect(isInitialized) { LaunchedEffect(isInitialized) {

View File

@ -1,8 +1,6 @@
package net.codinux.banking.ui package net.codinux.banking.ui
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -11,15 +9,6 @@ import kotlinx.coroutines.Dispatchers
expect val Dispatchers.IOorDefault: CoroutineDispatcher expect val Dispatchers.IOorDefault: CoroutineDispatcher
expect fun KeyEvent.isBackButtonPressedEvent(): Boolean
@Composable
expect fun systemPaddings(): PaddingValues
expect fun addKeyboardVisibilityListener(onKeyboardVisibilityChanged: (Boolean) -> Unit)
@Composable @Composable
expect fun rememberScreenSizeInfo(): ScreenSizeInfo expect fun rememberScreenSizeInfo(): ScreenSizeInfo

View File

@ -40,17 +40,12 @@ fun BottomBar(showMenuDrawer: Boolean = true) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
fun toggleDrawerState() {
coroutineScope.launch {
uiState.drawerState.value.toggle()
}
}
BottomAppBar { BottomAppBar {
if (showMenuDrawer) { if (showMenuDrawer) {
IconButton( IconButton(
onClick = { toggleDrawerState() } onClick = { coroutineScope.launch {
uiState.drawerState.value.toggle()
} }
) { ) {
Icon(Icons.Filled.Menu, contentDescription = "Open Navigation Drawer with sidebar menu") Icon(Icons.Filled.Menu, contentDescription = "Open Navigation Drawer with sidebar menu")
} }
@ -66,18 +61,14 @@ fun BottomBar(showMenuDrawer: Boolean = true) {
val selectedAccount = transactionsFilter.selectedAccount val selectedAccount = transactionsFilter.selectedAccount
val title = if (selectedAccount == null) { val title = if (selectedAccount == null) {
if (banks.isEmpty()) {
"Bankmeister" "Bankmeister"
} else {
"Alle Konten"
}
} else if (selectedAccount.bankAccount != null) { } else if (selectedAccount.bankAccount != null) {
selectedAccount.bankAccount.displayName selectedAccount.bankAccount.displayName
} else { } else {
selectedAccount.bank.displayName selectedAccount.bank.displayName
} }
Text(title, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clickable { toggleDrawerState() }) Text(title, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }

View File

@ -1,26 +1,19 @@
package net.codinux.banking.ui.appskeleton package net.codinux.banking.ui.appskeleton
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex 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.DI
import net.codinux.banking.ui.config.Internationalization import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.forms.RoundedCornersCard import net.codinux.banking.ui.forms.RoundedCornersCard
import net.codinux.banking.ui.forms.Select import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.isBackButtonPressedEvent import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.TransactionsGrouping
private val uiState = DI.uiState private val uiState = DI.uiState
@ -44,30 +37,14 @@ 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 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( Box(
contentAlignment = Alignment.BottomEnd, contentAlignment = Alignment.BottomEnd,
modifier = Modifier.fillMaxSize().zIndex(100f) modifier = Modifier.fillMaxSize().zIndex(100f)
.padding(bottom = 64.dp, end = 74.dp).focusable(true).focusRequester(filterBarFocus).focusTarget().onKeyEvent { event -> .padding(bottom = 64.dp, end = 74.dp)
if (event.isBackButtonPressedEvent() || event.key == Key.Escape) {
DI.uiState.showFilterBar.value = false
true
} else {
false
}
}
) { ) {
Column(Modifier.height(190.dp).width(390.dp)) { Column(Modifier.height(230.dp).width(390.dp)) {
RoundedCornersCard(cornerSize = 4.dp, shadowElevation = 24.dp) { RoundedCornersCard(cornerSize = 4.dp, shadowElevation = 24.dp) {
Column(Modifier.fillMaxWidth().background(Color.White).padding(16.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) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Umsätze", Modifier.width(labelsWidth)) Text("Umsätze", Modifier.width(labelsWidth))
@ -104,14 +81,13 @@ fun FilterBar() {
) )
} }
} }
}
}
}
}
Row(Modifier.padding(top = 10.dp)) {
LaunchedEffect(filterBarFocus) { Text("Zum Schließen bitte wieder auf das Filter Icon klicken, Zurück Button etc. funtioniert nicht (herzlichen Undank UI Framework!)")
filterBarFocus.requestFocus() // focus filter bar so that it receives key events to handle e.g. Escape button press }
}
}
}
} }
} }

View File

@ -1,113 +0,0 @@
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))
}
}
}

View File

@ -5,10 +5,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons 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.Add
import androidx.compose.material.icons.filled.SaveAs import androidx.compose.material.icons.filled.SaveAs
import androidx.compose.material.icons.outlined.Key
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -25,7 +23,7 @@ import net.codinux.banking.ui.composables.settings.UiSettings
import net.codinux.banking.ui.composables.text.ItemDivider import net.codinux.banking.ui.composables.text.ItemDivider
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.rememberVerticalScroll import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
import org.jetbrains.compose.resources.imageResource import org.jetbrains.compose.resources.imageResource
private val uiState = DI.uiState private val uiState = DI.uiState
@ -56,11 +54,11 @@ private val VerticalSpacing = 8.dp
fun SideMenuContent() { fun SideMenuContent() {
val drawerState = uiState.drawerState.collectAsState().value val drawerState = uiState.drawerState.collectAsState().value
val accounts = uiState.accounts.collectAsState().value val accounts = uiState.banks.collectAsState().value
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Column(Modifier.fillMaxSize().background(Colors.DrawerContentBackground).rememberVerticalScroll()) { Column(Modifier.fillMaxSize().background(Colors.DrawerContentBackground).verticalScroll(ScrollState(0), enabled = true)) {
Column(Modifier.fillMaxWidth().height(HeaderHeight.dp).background(HeaderBackground).padding(16.dp)) { Column(Modifier.fillMaxWidth().height(HeaderHeight.dp).background(HeaderBackground).padding(16.dp)) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
@ -68,12 +66,12 @@ fun SideMenuContent() {
Text("Bankmeister", color = Color.White, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)) Text("Bankmeister", color = Color.White, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp))
Text("Version 1.0.0 Alpha 15", color = Color.LightGray) Text("Version 1.0.0 Alpha 12", color = Color.LightGray)
} }
ItemDivider(color = Colors.DrawerDivider) ItemDivider(color = Colors.DrawerDivider)
Column(Modifier.padding(vertical = 24.dp).padding(start = 16.dp, end = 4.dp)) { Column(Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) {
Column(Modifier.height(ItemHeight), verticalArrangement = Arrangement.Center) { Column(Modifier.height(ItemHeight), verticalArrangement = Arrangement.Center) {
Text("Konten", color = textColor) Text("Konten", color = textColor)
} }
@ -96,6 +94,19 @@ fun SideMenuContent() {
drawerState.close() 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()) { if (accounts.isNotEmpty()) {
@ -112,24 +123,6 @@ fun SideMenuContent() {
drawerState.close() 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()
}
}
} }
} }
} }

View File

@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
import net.codinux.banking.client.model.BankAccess import net.codinux.banking.client.model.BankAccess
import net.codinux.banking.client.model.BankViewInfo import net.codinux.banking.client.model.BankViewInfo
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.bankfinder.BankInfo import net.codinux.banking.ui.model.BankInfo
import net.dankito.banking.banklistcreator.prettifier.BankingGroupMapper import net.dankito.banking.banklistcreator.prettifier.BankingGroupMapper
private val bankIconService = DI.bankIconService private val bankIconService = DI.bankIconService
@ -31,7 +31,7 @@ private val bankingGroupMapper = BankingGroupMapper()
@Composable @Composable
fun BankIcon(bank: BankInfo, modifier: Modifier = Modifier, iconModifier: Modifier = DefaultIconModifier, fallbackIcon: ImageVector? = null) { fun BankIcon(bank: BankInfo, modifier: Modifier = Modifier, iconModifier: Modifier = DefaultIconModifier, fallbackIcon: ImageVector? = null) {
val iconUrl by remember(bank.bic) { mutableStateOf(bankIconService.findIconForBank(bank.name, 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) BankIcon(iconUrl, modifier, iconModifier, fallbackIcon = fallbackIcon)
} }

View File

@ -10,8 +10,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.codinux.banking.persistence.entities.BankAccountEntity import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.BankAccessEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
private val uiState = DI.uiState private val uiState = DI.uiState
@ -44,7 +44,7 @@ fun BanksList(
accountSelected?.invoke(bank, null) accountSelected?.invoke(bank, null)
} }
bank.accountsSorted.filterNot { it.hideAccount }.forEach { account -> bank.accounts.sortedBy { it.displayIndex }.forEach { account ->
NavigationMenuItem(itemModifier, account.displayName, textColor, iconSize, IconTextSpacing, itemHorizontalPadding, bankAccount = account) { NavigationMenuItem(itemModifier, account.displayName, textColor, iconSize, IconTextSpacing, itemHorizontalPadding, bankAccount = account) {
accountSelected?.invoke(bank, account) accountSelected?.invoke(bank, account)
} }

View File

@ -1,22 +0,0 @@
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)
}
}

View File

@ -2,13 +2,13 @@ package net.codinux.banking.ui.composables
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ContentAlpha import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.Text 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.codinux.banking.persistence.entities.BankAccountEntity import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.BankAccessEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
@ -94,8 +94,6 @@ fun NavigationMenuItem(
bankAccount.balance bankAccount.balance
} else if (bank != null) { } else if (bank != null) {
calculator.calculateBalanceOfBankAccess(bank) calculator.calculateBalanceOfBankAccess(bank)
} else if (text == "Alle Konten") {
calculator.calculateBalanceOfAllAccounts(DI.uiState.accounts.value)
} else { } else {
null null
} }
@ -103,20 +101,9 @@ fun NavigationMenuItem(
if (balance != null) { if (balance != null) {
Text( Text(
formatUtil.formatAmount(balance, calculator.getTransactionsCurrency(emptyList())), formatUtil.formatAmount(balance, calculator.getTransactionsCurrency(emptyList())),
color = if (showColoredAmounts) formatUtil.getColorForAmount(balance, showColoredAmounts) else textColor, color = formatUtil.getColorForAmount(balance, showColoredAmounts),
modifier = Modifier.padding(start = 8.dp) modifier = Modifier.padding(start = 4.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))
}
} }
} }

View File

@ -6,7 +6,7 @@ import androidx.compose.runtime.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.dialogs.* import net.codinux.banking.ui.dialogs.*
import net.codinux.banking.ui.screens.* import net.codinux.banking.ui.screens.ExportScreen
import net.codinux.banking.ui.state.UiState import net.codinux.banking.ui.state.UiState
private val formatUtil = DI.formatUtil private val formatUtil = DI.formatUtil
@ -15,16 +15,7 @@ private val formatUtil = DI.formatUtil
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) { fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState() val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.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 showExportScreen by uiState.showExportScreen.collectAsState()
val showFeedbackScreen by uiState.showFeedbackScreen.collectAsState()
val showProtectAppSettingsScreen by uiState.showProtectAppSettingsScreen.collectAsState()
val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState() val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState()
val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState() val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState()
@ -41,42 +32,10 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null } 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) { if (showExportScreen) {
ExportScreen { uiState.showExportScreen.value = false } 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 -> tanChallengeReceived?.let { tanChallengeReceived ->
EnterTanDialog(tanChallengeReceived) { EnterTanDialog(tanChallengeReceived) {

View File

@ -1,24 +0,0 @@
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))
}
}
}

View File

@ -13,7 +13,7 @@ import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Internationalization import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.forms.BooleanOption import net.codinux.banking.ui.forms.BooleanOption
import net.codinux.banking.ui.forms.Select import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.settings.TransactionsGrouping import net.codinux.banking.ui.model.TransactionsGrouping
@Composable @Composable
fun UiSettings(modifier: Modifier, textColor: Color = Color.Unspecified) { fun UiSettings(modifier: Modifier, textColor: Color = Color.Unspecified) {

View File

@ -1,46 +0,0 @@
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)) {
}
}
}

View File

@ -1,193 +0,0 @@
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()
}
}
}

View File

@ -1,28 +0,0 @@
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)
}
}
}

View File

@ -1,54 +0,0 @@
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)
}
}

View File

@ -3,15 +3,14 @@ package net.codinux.banking.ui.composables.text
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import net.codinux.banking.ui.config.Style import net.codinux.banking.ui.config.Style
@Composable @Composable
fun HeaderText(title: String, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start, textColor: Color = Style.HeaderTextColor) { fun HeaderText(title: String, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start) {
Text( Text(
title, title,
color = textColor, color = Style.HeaderTextColor,
fontSize = Style.HeaderFontSize, fontSize = Style.HeaderFontSize,
fontWeight = Style.HeaderFontWeight, fontWeight = Style.HeaderFontWeight,
modifier = modifier, modifier = modifier,

View File

@ -13,14 +13,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import net.codinux.banking.client.model.Amount import net.codinux.banking.client.model.Amount
import net.codinux.banking.persistence.entities.BankAccessEntity import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.persistence.entities.HoldingEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style import net.codinux.banking.ui.config.Style
import net.codinux.banking.ui.forms.RoundedCornersCard import net.codinux.banking.ui.forms.RoundedCornersCard
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.settings.TransactionsGrouping import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.service.TransactionsGroupingService import net.codinux.banking.ui.service.TransactionsGroupingService
private val calculator = DI.calculator private val calculator = DI.calculator
@ -31,7 +31,7 @@ private val formatUtil = DI.formatUtil
fun GroupedTransactionsListItems( fun GroupedTransactionsListItems(
modifier: Modifier, modifier: Modifier,
transactionsToDisplay: List<AccountTransactionViewModel>, transactionsToDisplay: List<AccountTransactionViewModel>,
holdingsToDisplay: List<HoldingEntity>, holdingsToDisplay: List<Holding>,
banksById: Map<Long, BankAccessEntity>, banksById: Map<Long, BankAccessEntity>,
transactionsGrouping: TransactionsGrouping transactionsGrouping: TransactionsGrouping
) { ) {
@ -65,9 +65,9 @@ fun GroupedTransactionsListItems(
RoundedCornersCard { RoundedCornersCard {
Column(Modifier.background(Color.White)) { Column(Modifier.background(Color.White)) {
holdingsToDisplay.forEachIndexed { index, holding -> holdingsToDisplay.forEachIndexed { index, holding ->
key(holding.id) { // key(statementOfHoldings.id) {
HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1) HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1)
} // }
} }
} }
} }
@ -76,7 +76,6 @@ fun GroupedTransactionsListItems(
} }
items(groupedByDate.keys.sortedDescending()) { groupingDate -> items(groupedByDate.keys.sortedDescending()) { groupingDate ->
key(groupingDate.toEpochDays()) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text( Text(
text = DI.formatUtil.formatGroupingDate(groupingDate, transactionsGrouping), text = DI.formatUtil.formatGroupingDate(groupingDate, transactionsGrouping),
@ -124,5 +123,4 @@ fun GroupedTransactionsListItems(
} }
} }
} }
}
} }

View File

@ -70,7 +70,7 @@ fun HoldingListItem(holding: Holding, isOddItem: Boolean = false, isNotLastItem:
Row(Modifier.weight(1f).padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.weight(1f).padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically) {
// TODO: set maxLines = 1 and TextOverflow.Ellipsis // TODO: set maxLines = 1 and TextOverflow.Ellipsis
if (holding.quantity != null) { if (holding.quantity != null) {
Text(formatUtil.formatQuantity(holding.quantity) + " Stück, ") Text(holding.quantity.toString() + " Stück, ")
} }
if (holding.averageCostPrice != null) { if (holding.averageCostPrice != null) {

View File

@ -51,11 +51,11 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
DI.uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData( DI.uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData(
DI.uiState.banks.value.firstNotNullOf { it.accounts.firstOrNull { it.id == transaction.accountId } }, DI.uiState.banks.value.firstNotNullOf { it.accounts.firstOrNull { it.id == transaction.accountId } },
transaction.otherPartyName, // we don't use userSetOtherPartyName here on purpose transaction.otherPartyName,
transactionEntity?.otherPartyBankId, transactionEntity?.otherPartyBankId,
transactionEntity?.otherPartyAccountId, transactionEntity?.otherPartyAccountId,
if (withSameData) transaction.amount else null, if (withSameData) transaction.amount else null,
if (withSameData) transaction.reference else null // we don't use userSetReference here on purpose if (withSameData) transaction.reference else null
) )
} }
} }
@ -66,7 +66,6 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
.background(color = backgroundColor) .background(color = backgroundColor)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onTap = { DI.uiState.showAccountTransactionDetailsScreenForId.value = transaction.id },
onLongPress = { onLongPress = {
if (transaction.otherPartyName != null) { // TODO: also check if IBAN is set if (transaction.otherPartyName != null) { // TODO: also check if IBAN is set
showMenuAt = DpOffset(it.x.dp, it.y.dp - bottomPadding) showMenuAt = DpOffset(it.x.dp, it.y.dp - bottomPadding)
@ -83,7 +82,7 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
} }
Text( Text(
text = transaction.userSetOtherPartyName ?: transaction.otherPartyName ?: transaction.postingText ?: "", text = transaction.otherPartyName ?: transaction.postingText ?: "",
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
color = Style.ListItemHeaderTextColor, color = Style.ListItemHeaderTextColor,
fontWeight = Style.ListItemHeaderWeight, fontWeight = Style.ListItemHeaderWeight,
@ -95,7 +94,7 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = transaction.userSetReference ?: transaction.reference ?: "", text = transaction.reference ?: "",
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@ -121,7 +120,7 @@ fun TransactionListItem(bank: BankAccess?, transaction: AccountTransactionViewMo
offset = showMenuAt ?: DpOffset.Zero, offset = showMenuAt ?: DpOffset.Zero,
) { ) {
DropdownMenuItem({ newMoneyTransferToOtherParty(false) }) { DropdownMenuItem({ newMoneyTransferToOtherParty(false) }) {
Text("Neue Überweisung an ${transaction.userSetOtherPartyName ?: transaction.otherPartyName} ...") // really use userSetOtherPartyName here as we don't use it in ShowTransferMoneyDialogData Text("Neue Überweisung an ${transaction.otherPartyName} ...")
} }
DropdownMenuItem({ newMoneyTransferToOtherParty(true) }) { DropdownMenuItem({ newMoneyTransferToOtherParty(true) }) {

View File

@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.settings.TransactionsGrouping import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.settings.UiSettings import net.codinux.banking.ui.settings.UiSettings
import net.codinux.banking.ui.state.UiState import net.codinux.banking.ui.state.UiState
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@ -69,9 +69,9 @@ fun TransactionsList(uiState: UiState, uiSettings: UiSettings, isMobile: Boolean
} else { } else {
LazyColumn(transactionsListModifier, contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp)) { LazyColumn(transactionsListModifier, contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp)) {
itemsIndexed(holdingsToDisplay) { index, holding -> itemsIndexed(holdingsToDisplay) { index, holding ->
key(holding.id) { // key(holding.isin) {
HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1) HoldingListItem(holding, index % 2 == 1, index < holdingsToDisplay.size - 1)
} // }
} }
itemsIndexed(transactionsToDisplay) { index, transaction -> itemsIndexed(transactionsToDisplay) { index, transaction ->

View File

@ -22,9 +22,6 @@ object Colors {
val BackgroundColorLight = Color("#FFFFFF") val BackgroundColorLight = Color("#FFFFFF")
val MaterialThemeTextColor = Color(0xFF4F4F4F) // to match dialog's text color of Material theme
val DrawerContentBackground = BackgroundColorDark val DrawerContentBackground = BackgroundColorDark
val DrawerPrimaryText = PrimaryTextColorDark val DrawerPrimaryText = PrimaryTextColorDark
@ -37,16 +34,6 @@ object Colors {
val CodinuxSecondaryColor = Color(251, 187, 33) 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 = Color(244, 244, 245)
val Zinc100_50 = Zinc100.copy(alpha = 0.5f) val Zinc100_50 = Zinc100.copy(alpha = 0.5f)

View File

@ -1,7 +1,9 @@
package net.codinux.banking.ui.config package net.codinux.banking.ui.config
import net.codinux.banking.persistence.BankingRepository import app.cash.sqldelight.db.SqlDriver
import net.codinux.banking.persistence.InMemoryBankingRepository import net.codinux.banking.dataaccess.BankingRepository
import net.codinux.banking.dataaccess.InMemoryBankingRepository
import net.codinux.banking.dataaccess.SqliteBankingRepository
import net.codinux.banking.ui.Platform import net.codinux.banking.ui.Platform
import net.codinux.banking.ui.getPlatform import net.codinux.banking.ui.getPlatform
import net.codinux.banking.ui.service.* import net.codinux.banking.ui.service.*
@ -29,22 +31,18 @@ object DI {
val accountTransactionsFilterService = AccountTransactionsFilterService() val accountTransactionsFilterService = AccountTransactionsFilterService()
val epcQrCodeService = EpcQrCodeService()
val uiService = UiService() val uiService = UiService()
var bankingRepository: BankingRepository = InMemoryBankingRepository(emptyList()) var bankingRepository: BankingRepository = InMemoryBankingRepository(emptyList())
val bankingService by lazy { BankingService(uiState, uiSettings, uiService, bankingRepository, bankFinder) } val bankingService by lazy { BankingService(uiState, uiSettings, bankingRepository, bankFinder) }
fun setRepository(sqlDriver: SqlDriver) = setRepository(SqliteBankingRepository(sqlDriver))
fun setRepository(repository: BankingRepository) { fun setRepository(repository: BankingRepository) {
this.bankingRepository = repository this.bankingRepository = repository
repository.getAppSettings()?.let { // otherwise it's the first app start, BankingService will take care of this case
uiState.appSettings.value = it
}
} }

View File

@ -1,9 +1,7 @@
package net.codinux.banking.ui.config 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.client.model.tan.ActionRequiringTan
import net.codinux.banking.ui.model.settings.TransactionsGrouping import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
object Internationalization { object Internationalization {
@ -13,12 +11,6 @@ object Internationalization {
const val ErrorTransferMoney = "Überweisung konnte nicht ausgeführt werden" 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) { fun getTextForActionRequiringTan(action: ActionRequiringTan): String = when (action) {
ActionRequiringTan.GetAnonymousBankInfo, ActionRequiringTan.GetAnonymousBankInfo,
@ -38,23 +30,4 @@ object Internationalization {
TransactionsGrouping.None -> "Nicht gruppieren" 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"
}
} }

View File

@ -19,15 +19,6 @@ object Style {
val ListItemHeaderWeight = FontWeight.Medium // couldn't believe it, the FontWeights look different on Desktop and Android 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 val DividerThickness = 1.dp
} }

View File

@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -19,11 +20,9 @@ import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.composables.BankIcon import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.ImeNext
import net.codinux.banking.ui.forms.* import net.codinux.banking.ui.forms.*
import net.codinux.banking.ui.forms.OutlinedTextField import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.bankfinder.BankInfo import net.codinux.banking.ui.model.BankInfo
import net.codinux.log.Log
private val bankingService = DI.bankingService private val bankingService = DI.bankingService
@ -38,7 +37,7 @@ fun AddAccountDialog(
var selectedBank by remember { mutableStateOf<BankInfo?>(null) } var selectedBank by remember { mutableStateOf<BankInfo?>(null) }
var loginName by remember { mutableStateOf("") } var loginName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var retrieveAllTransactions by remember { mutableStateOf(true) } var retrieveAllTransactions by remember { mutableStateOf(false) }
val isRequiredDataEntered by remember(selectedBank, loginName, password) { val isRequiredDataEntered by remember(selectedBank, loginName, password) {
derivedStateOf { selectedBank != null && loginName.length > 3 && password.length > 3 } derivedStateOf { selectedBank != null && loginName.length > 3 && password.length > 3 }
@ -70,12 +69,7 @@ fun AddAccountDialog(
isAddingAccount = true isAddingAccount = true
addAccountJob = coroutineScope.launch(Dispatchers.IOorDefault) { addAccountJob = coroutineScope.launch(Dispatchers.IOorDefault) {
val successful = try { val successful = DI.bankingService.addAccount(bank, loginName, password, retrieveAllTransactions)
DI.bankingService.addAccount(bank, loginName, password, retrieveAllTransactions)
} catch (e: Throwable) {
Log.error(e) { "Could not add account for $bank" }
false
}
addAccountJob = null addAccountJob = null
@ -142,11 +136,9 @@ fun AddAccountDialog(
} }
Row(Modifier.fillMaxWidth().padding(top = 6.dp)) { Row(Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text(bank.bankCode, color = textColor) Text(bank.domesticBankCode, color = textColor)
Text((bank.bic ?: "").padEnd(11, ' '), color = textColor, modifier = Modifier.padding(horizontal = 8.dp)) Text("${bank.postalCode} ${bank.city}", Modifier.weight(1f).padding(start = 8.dp), color = if (supportsFinTs) Color.Gray else textColor)
Text("${bank.postalCode} ${bank.city}", Modifier.weight(1f), color = if (supportsFinTs) Color.Gray else textColor)
} }
} }
} }
@ -163,7 +155,7 @@ fun AddAccountDialog(
onValueChange = { loginName = it }, onValueChange = { loginName = it },
label = { Text("Login Name") }, label = { Text("Login Name") },
modifier = Modifier.fillMaxWidth().focusRequester(loginNameFocus), modifier = Modifier.fillMaxWidth().focusRequester(loginNameFocus),
keyboardOptions = KeyboardOptions.ImeNext keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

@ -11,10 +11,9 @@ fun ApplicationErrorDialog(error: ApplicationError, onDismiss: (() -> Unit)? = n
ErroneousAction.AddAccount -> Internationalization.ErrorAddAccount ErroneousAction.AddAccount -> Internationalization.ErrorAddAccount
ErroneousAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions ErroneousAction.UpdateAccountTransactions -> Internationalization.ErrorUpdateAccountTransactions
ErroneousAction.TransferMoney -> Internationalization.ErrorTransferMoney ErroneousAction.TransferMoney -> Internationalization.ErrorTransferMoney
ErroneousAction.ReadEpcQrCode -> Internationalization.ErrorReadEpcQrCode
ErroneousAction.SaveToDatabase -> Internationalization.ErrorSaveToDatabase
ErroneousAction.BiometricAuthentication -> Internationalization.ErrorBiometricAuthentication
} }
ErrorDialog(error.errorMessage, title, error.exception, onDismiss = onDismiss) // add exception stacktrace?
ErrorDialog(error.errorMessage, title, onDismiss = onDismiss)
} }

View File

@ -3,12 +3,9 @@ package net.codinux.banking.ui.dialogs
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* 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.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -16,25 +13,17 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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.composables.text.HeaderText
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style
import net.codinux.banking.ui.extensions.applyPlatformSpecificPaddingIf
import net.codinux.banking.ui.extensions.copy import net.codinux.banking.ui.extensions.copy
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.* import net.codinux.banking.ui.forms.*
@Composable @Composable
fun BaseDialog( fun BaseDialog(
title: String, title: String,
centerTitle: Boolean = false,
confirmButtonTitle: String = "OK", confirmButtonTitle: String = "OK",
confirmButtonEnabled: Boolean = true, confirmButtonEnabled: Boolean = true,
dismissButtonTitle: String = "Abbrechen",
showProgressIndicatorOnConfirmButton: Boolean = false, showProgressIndicatorOnConfirmButton: Boolean = false,
useMoreThanPlatformDefaultWidthOnMobile: Boolean = false, useMoreThanPlatformDefaultWidthOnMobile: Boolean = false,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@ -44,26 +33,25 @@ fun BaseDialog(
) { ) {
val overwriteDefaultWidth = useMoreThanPlatformDefaultWidthOnMobile && DI.platform.isMobile val overwriteDefaultWidth = useMoreThanPlatformDefaultWidthOnMobile && DI.platform.isMobile
var isKeyboardVisible by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss, if (overwriteDefaultWidth) properties.copy(usePlatformDefaultWidth = false) else properties) { Dialog(onDismissRequest = onDismiss, if (overwriteDefaultWidth) properties.copy(usePlatformDefaultWidth = false) else properties) {
RoundedCornersCard(Modifier.let { if (overwriteDefaultWidth) it.fillMaxWidth(0.95f) else it }) { RoundedCornersCard(Modifier.let { if (overwriteDefaultWidth) it.fillMaxWidth(0.95f) else it }) {
Column(Modifier.applyPlatformSpecificPaddingIf(overwriteDefaultWidth && isKeyboardVisible, 8.dp).background(Color.White).padding(horizontal = 8.dp).verticalScroll()) { Column(Modifier.background(Color.White).padding(8.dp)) {
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp).height(32.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth()) {
HeaderText(title, Modifier.fillMaxWidth().weight(1f), textColor = Style.ListItemHeaderTextColor, textAlign = if (centerTitle) TextAlign.Center else TextAlign.Start) HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f))
if (DI.platform.type != PlatformType.Android) { // for iOS it's also relevant due to the missing back gesture / back button if (DI.platform.isDesktop) {
CloseButton(onClick = onDismiss) TextButton(onDismiss, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.Close, contentDescription = "Close dialog", Modifier.size(32.dp))
}
} }
} }
content() content()
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) { Row(Modifier.fillMaxWidth()) {
TextButton(onClick = onDismiss, Modifier.weight(0.5f)) { TextButton(onClick = onDismiss, Modifier.weight(0.5f)) {
Text(dismissButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) Text("Abbrechen", color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
} }
TextButton( TextButton(
@ -73,7 +61,7 @@ fun BaseDialog(
) { ) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (showProgressIndicatorOnConfirmButton) { if (showProgressIndicatorOnConfirmButton) {
CircularProgressIndicator(Modifier.padding(end = 6.dp).size(36.dp), color = Colors.CodinuxSecondaryColor) CircularProgressIndicator(Modifier.padding(end = 6.dp), color = Colors.CodinuxSecondaryColor)
} }
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
@ -83,13 +71,4 @@ 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
}
}
}
} }

View File

@ -1,32 +0,0 @@
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))
}
}

View File

@ -1,34 +1,33 @@
package net.codinux.banking.ui.dialogs package net.codinux.banking.ui.dialogs
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme import androidx.compose.material.*
import androidx.compose.material.Text 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.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import net.codinux.banking.client.model.tan.ActionRequiringTan import net.codinux.banking.client.model.tan.*
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.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.DI
import net.codinux.banking.ui.config.Internationalization 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.OutlinedTextField
import net.codinux.banking.ui.forms.Select 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.TanChallengeReceived
import net.codinux.banking.ui.model.error.ErroneousAction 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.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ -42,7 +41,9 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
val isNotADecoupledTanMethod = !!!isDecoupledMethod val isNotADecoupledTanMethod = !!!isDecoupledMethod
var showSelectingTanMediumNotImplementedWarning by remember { mutableStateOf(false) } var tanImageHeight by remember { mutableStateOf(250) }
val minTanImageHeight = 100
val maxTanImageHeight = 500
val textFieldFocus = remember { FocusRequester() } val textFieldFocus = remember { FocusRequester() }
@ -97,10 +98,10 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
Text("${challenge.bank.bankName}, Nutzer ${challenge.bank.loginName}${challenge.account?.let { ", Konto ${it.productName ?: it.identifier}" } ?: ""}") Text("${challenge.bank.bankName}, Nutzer ${challenge.bank.loginName}${challenge.account?.let { ", Konto ${it.productName ?: it.identifier}" } ?: ""}")
} }
Row(Modifier.padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically) { Text(
Text("TAN benötigt ") "TAN benötigt ${Internationalization.getTextForActionRequiringTan(challenge.forAction)}",
Text(Internationalization.getTextForActionRequiringTan(challenge.forAction), fontWeight = FontWeight.Bold) Modifier.padding(top = 6.dp)
} )
} }
@ -109,9 +110,19 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
"TAN Verfahren", "TAN Verfahren",
challenge.availableTanMethods.sortedBy { it.identifier }, challenge.availableTanMethods.sortedBy { it.identifier },
challenge.selectedTanMethod, challenge.selectedTanMethod,
{ tanMethod -> tanChallengeReceived.callback(EnterTanResult(null, tanMethod)) }, { tanMethod ->
if (tanMethod.type != TanMethodType.ChipTanFlickercode) {
tanChallengeReceived.callback(EnterTanResult(null, tanMethod))
}
},
{ it.displayName } { it.displayName }
) { tanMethod -> Text(tanMethod.displayName) } ) { tanMethod ->
if (tanMethod.type == TanMethodType.ChipTanFlickercode) {
Text(tanMethod.displayName + " (noch nicht implementiert)", color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled))
} else {
Text(tanMethod.displayName)
}
}
} }
if (challenge.availableTanMedia.isNotEmpty()) { if (challenge.availableTanMedia.isNotEmpty()) {
@ -120,35 +131,39 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
"TAN Medium", "TAN Medium",
challenge.availableTanMedia.sortedBy { it.status }.map { it.displayName }, challenge.availableTanMedia.sortedBy { it.status }.map { it.displayName },
challenge.selectedTanMedium?.displayName ?: "<Keines ausgewählt>", challenge.selectedTanMedium?.displayName ?: "<Keines ausgewählt>",
{ showSelectingTanMediumNotImplementedWarning = true }, // TODO: change TanMedium { Log.info { "User selected TanMedium $it" } }, // TODO: change TanMethod
{ it } { 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) { if (challenge.tanImage != null || challenge.flickerCode != null) {
Column(Modifier.fillMaxWidth().padding(top = 6.dp)) { Column(Modifier.fillMaxWidth().padding(top = 6.dp)) {
val textColor = Colors.MaterialThemeTextColor // to match dialog's text color of Material theme 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.")
challenge.flickerCode?.let { flickerCode -> 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))
ChipTanFlickerCodeView(flickerCode, textColor)
} }
challenge.tanImage?.let { tanImage -> challenge.tanImage?.let { tanImage ->
tanImage.decodingError?.let { if (tanImage.decodingSuccessful) {
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)) 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.imageBytesBase64?.let { imageBytesBase64 -> Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
val imageBytes = Base64.decode(imageBytesBase64) Image(createImageBitmap(imageBytes), "Bild mit enkodierter TAN", Modifier.height(tanImageHeight.dp), contentScale = ContentScale.FillHeight)
}
// 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)
} }
} }
} }

View File

@ -7,32 +7,23 @@ import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign 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.composables.text.HeaderText
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.extensions.verticalScroll import net.codinux.banking.ui.config.Style
import net.codinux.banking.ui.model.Config.NewLine
@Composable @Composable
fun ErrorDialog( fun ErrorDialog(
text: String, text: String,
title: String? = null, title: String? = null,
exception: Throwable? = null,
confirmButtonText: String = "OK", confirmButtonText: String = "OK",
onDismiss: (() -> Unit)? = null onDismiss: (() -> Unit)? = null
) { ) {
val effectiveText = if (exception == null) text else {
"$text${NewLine}${NewLine}Fehlermeldung:${NewLine}${exception.stackTraceToString()}"
}
AlertDialog( AlertDialog(
text = { Text(effectiveText, Modifier.verticalScroll()) }, text = { Text(text) },
title = { title?.let { title = { title?.let {
HeaderText(title, Modifier.fillMaxWidth(), TextAlign.Center) HeaderText(title, Modifier.fillMaxWidth(), TextAlign.Center)
} }, } },
properties = if (exception == null) DialogProperties() else DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = { onDismiss?.invoke() }, onDismissRequest = { onDismiss?.invoke() },
confirmButton = { confirmButton = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {

View File

@ -20,9 +20,7 @@ import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.composables.BankIcon import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.ImeNext
import net.codinux.banking.ui.forms.AutocompleteTextField 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.OutlinedTextField
import net.codinux.banking.ui.forms.Select import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
@ -42,9 +40,10 @@ fun TransferMoneyDialog(
) { ) {
val banks = uiState.banks.value val banks = uiState.banks.value
val accountsToBank = banks.sortedBy { it.displayIndex } val accountsToBank = banks.sortedBy { it.displayIndex }
.flatMap { bank -> bank.accountsSorted.map { it to bank } }.toMap() .flatMap { bank -> bank.accounts.sortedBy { it.displayIndex }.map { it to bank } }.toMap()
val accountsSupportingTransferringMoney = uiState.accountsThatSupportMoneyTransfer.collectAsState().value val accountsSupportingTransferringMoney = banks.flatMap { it.accounts }
.filter { it.supportsMoneyTransfer }
if (accountsSupportingTransferringMoney.isEmpty()) { if (accountsSupportingTransferringMoney.isEmpty()) {
uiState.applicationErrorOccurred(ErroneousAction.TransferMoney, "Keines Ihrer Konten unterstützt das Überweisen von Geld") uiState.applicationErrorOccurred(ErroneousAction.TransferMoney, "Keines Ihrer Konten unterstützt das Überweisen von Geld")
@ -57,7 +56,7 @@ fun TransferMoneyDialog(
var recipientName by remember { mutableStateOf(data.recipientName ?: "") } var recipientName by remember { mutableStateOf(data.recipientName ?: "") }
var recipientAccountIdentifier by remember { mutableStateOf(data.recipientAccountIdentifier ?: "") } var recipientAccountIdentifier by remember { mutableStateOf(data.recipientAccountIdentifier ?: "") }
var amount by remember { mutableStateOf(data.amount?.toString() ?: "") } var amount by remember { mutableStateOf(data.amount.toString()) }
var paymentReference by remember { mutableStateOf(data.reference ?: "") } var paymentReference by remember { mutableStateOf(data.reference ?: "") }
val accountSupportsInstantTransfer by remember(senderAccount) { derivedStateOf { senderAccount.supportsInstantTransfer } } val accountSupportsInstantTransfer by remember(senderAccount) { derivedStateOf { senderAccount.supportsInstantTransfer } }
var instantTransfer by remember { mutableStateOf(false) } var instantTransfer by remember { mutableStateOf(false) }
@ -77,8 +76,6 @@ fun TransferMoneyDialog(
val amountFocus = remember { FocusRequester() } val amountFocus = remember { FocusRequester() }
val referenceFocus = remember { FocusRequester() }
val verticalSpace = 8.dp val verticalSpace = 8.dp
var isInitialized by remember { mutableStateOf(false) } var isInitialized by remember { mutableStateOf(false) }
@ -123,7 +120,7 @@ fun TransferMoneyDialog(
BaseDialog( BaseDialog(
title = "Neue Überweisung", title = "Neue Überweisung ...",
confirmButtonTitle = "Überweisen", confirmButtonTitle = "Überweisen",
confirmButtonEnabled = isRequiredDataEntered && isTransferringMoney == false, confirmButtonEnabled = isRequiredDataEntered && isTransferringMoney == false,
showProgressIndicatorOnConfirmButton = isTransferringMoney, showProgressIndicatorOnConfirmButton = isTransferringMoney,
@ -187,8 +184,6 @@ fun TransferMoneyDialog(
} }
} }
CaptionText("${recipientName.length} / 70")
Spacer(modifier = Modifier.height(verticalSpace)) Spacer(modifier = Modifier.height(verticalSpace))
OutlinedTextField( OutlinedTextField(
@ -196,7 +191,7 @@ fun TransferMoneyDialog(
onValueChange = { recipientAccountIdentifier = it }, onValueChange = { recipientAccountIdentifier = it },
label = { Text("IBAN") }, label = { Text("IBAN") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions.ImeNext keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
) )
Row(Modifier.padding(vertical = verticalSpace).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(vertical = verticalSpace).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@ -210,13 +205,12 @@ fun TransferMoneyDialog(
getItemTitle = { suggestion -> suggestion.amount.toString() }, getItemTitle = { suggestion -> suggestion.amount.toString() },
onEnteredTextChanged = { amount = it }, onEnteredTextChanged = { amount = it },
onSelectedItemChanged = { onSelectedItemChanged = {
amount = it?.amount.toString()
if (it != null) { if (it != null) {
amount = it.amount.toString()
paymentReference = it.reference paymentReference = it.reference
referenceFocus.requestFocus()
} }
}, },
fetchSuggestions = { query -> recipientFinder.findAmountPaymentDataForIban(recipientAccountIdentifier, query) } fetchSuggestions = { recipientFinder.findPaymentDataForIban(recipientAccountIdentifier) }
) { paymentDataSuggestion -> ) { paymentDataSuggestion ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(formatUtil.formatAmount(paymentDataSuggestion.amount, paymentDataSuggestion.currency), Modifier.widthIn(min = 60.dp), textAlign = TextAlign.End) Text(formatUtil.formatAmount(paymentDataSuggestion.amount, paymentDataSuggestion.currency), Modifier.widthIn(min = 60.dp), textAlign = TextAlign.End)
@ -231,13 +225,12 @@ fun TransferMoneyDialog(
AutocompleteTextField( AutocompleteTextField(
"Verwendungszweck (optional)", "Verwendungszweck (optional)",
paymentReference, paymentReference,
dropdownMaxHeight = 175.dp, // when showing more items than on Android autocomplete dropdown covers soft keyboard dropdownMaxHeight = 250.dp,
minTextLengthForSearch = 1, minTextLengthForSearch = 0,
modifier = Modifier.focusRequester(referenceFocus),
getItemTitle = { suggestion -> suggestion.reference }, getItemTitle = { suggestion -> suggestion.reference },
onEnteredTextChanged = { paymentReference = it }, onEnteredTextChanged = { paymentReference = it },
onSelectedItemChanged = { paymentReference = it?.reference ?: "" }, onSelectedItemChanged = { paymentReference = it?.reference ?: "" },
fetchSuggestions = { query -> recipientFinder.findReferencePaymentDataForIban(recipientAccountIdentifier, query) } fetchSuggestions = { recipientFinder.findPaymentDataForIban(recipientAccountIdentifier) }
) { paymentDataSuggestion -> ) { paymentDataSuggestion ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(formatUtil.formatAmount(paymentDataSuggestion.amount, paymentDataSuggestion.currency), Modifier.widthIn(min = 60.dp), textAlign = TextAlign.End) Text(formatUtil.formatAmount(paymentDataSuggestion.amount, paymentDataSuggestion.currency), Modifier.widthIn(min = 60.dp), textAlign = TextAlign.End)
@ -246,7 +239,12 @@ fun TransferMoneyDialog(
} }
} }
CaptionText("${paymentReference.length} / 140") Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.End) {
Text(
text = "${paymentReference.length} / 140",
style = MaterialTheme.typography.caption
)
}
Row(Modifier.padding(top = verticalSpace), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(top = verticalSpace), verticalAlignment = Alignment.CenterVertically) {
@ -275,13 +273,7 @@ fun TransferMoneyDialog(
coroutineScope.launch { 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 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() recipientNameFocus.requestFocus()
} else if (data.amount == null) {
amountFocus.requestFocus()
} else {
referenceFocus.requestFocus()
}
} }
} }
} }

View File

@ -1,11 +0,0 @@
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)

View File

@ -1,49 +0,0 @@
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()}" }
}
}

View File

@ -30,7 +30,6 @@ fun <T> AutocompleteTextField(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textFieldFocus: FocusRequester = remember { FocusRequester() }, textFieldFocus: FocusRequester = remember { FocusRequester() },
keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onEnterPressed: (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null,
fetchSuggestions: suspend (query: String) -> Collection<T> = { emptyList() }, fetchSuggestions: suspend (query: String) -> Collection<T> = { emptyList() },
suggestionContent: @Composable (T) -> Unit suggestionContent: @Composable (T) -> Unit
@ -103,8 +102,7 @@ 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 // 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

View File

@ -1,23 +0,0 @@
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
)
}
}

View File

@ -1,56 +0,0 @@
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)
}
}

View File

@ -1,38 +0,0 @@
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
)
}
}
}

View File

@ -17,16 +17,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable // try BasicSecureTextField @Composable // try BasicSecureTextField
fun PasswordTextField( fun PasswordTextField(password: String = "", label: String = "Passwort", forceHidePassword: Boolean? = null, onEnterPressed: (() -> Unit)? = null, onChange: (String) -> Unit) {
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) } var passwordVisible by remember { mutableStateOf(false) }
@ -38,8 +29,7 @@ fun PasswordTextField(
value = password, value = password,
onValueChange = { onChange(it) }, onValueChange = { onChange(it) },
label = { Text(label) }, label = { Text(label) },
modifier = modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
isError = isError,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = { trailingIcon = {
val visibilityIcon = if (passwordVisible) { val visibilityIcon = if (passwordVisible) {
@ -53,7 +43,7 @@ fun PasswordTextField(
modifier = Modifier.size(24.dp).clickable { passwordVisible = !passwordVisible } modifier = Modifier.size(24.dp).clickable { passwordVisible = !passwordVisible }
) )
}, },
keyboardOptions = keyboardOptions?.copy(keyboardType = KeyboardType.Password) ?: KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
onEnterPressed = onEnterPressed onEnterPressed = onEnterPressed
) )
} }

View File

@ -1,28 +0,0 @@
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
)
}

View File

@ -1,68 +0,0 @@
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)
}
}
}
}
}

View File

@ -3,7 +3,7 @@ package net.codinux.banking.ui.model
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount import net.codinux.banking.client.model.Amount
import net.codinux.banking.persistence.entities.AccountTransactionEntity import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
data class AccountTransactionViewModel( data class AccountTransactionViewModel(
val id: Long, val id: Long,
@ -17,8 +17,8 @@ data class AccountTransactionViewModel(
val otherPartyName: String? = null, val otherPartyName: String? = null,
val postingText: String? = null, val postingText: String? = null,
var userSetReference: String? = null, val userSetReference: String? = null,
var userSetOtherPartyName: String? = null val userSetOtherPartyName: String? = null
) { ) {
constructor(entity: AccountTransactionEntity) : this(entity.id, entity.bankId, entity.accountId, entity) constructor(entity: AccountTransactionEntity) : this(entity.id, entity.bankId, entity.accountId, entity)

View File

@ -1,8 +1,8 @@
package net.codinux.banking.ui.model package net.codinux.banking.ui.model
import androidx.compose.runtime.* import androidx.compose.runtime.*
import net.codinux.banking.persistence.entities.BankAccountEntity import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.BankAccessEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
class AccountTransactionsFilter { class AccountTransactionsFilter {

View File

@ -1,17 +0,0 @@
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"
}
}
}

View File

@ -1,7 +1,7 @@
package net.codinux.banking.ui.model package net.codinux.banking.ui.model
import net.codinux.banking.persistence.entities.BankAccountEntity import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.BankAccessEntity import net.codinux.banking.dataaccess.entities.BankAccessEntity
data class BankAccountFilter( data class BankAccountFilter(
val bank: BankAccessEntity, val bank: BankAccessEntity,

View File

@ -0,0 +1,27 @@
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"
}

View File

@ -1,7 +0,0 @@
package net.codinux.banking.ui.model
data class DecodeEpcQrCodeResult(
val data: ShowTransferMoneyDialogData?,
val error: String? = null,
val charset: String? = null
)

View File

@ -1,7 +1,5 @@
package net.codinux.banking.ui.model package net.codinux.banking.ui.model
import net.codinux.banking.bankfinder.BankInfo
data class RecipientSuggestion( data class RecipientSuggestion(
val name: String, val name: String,
val bankIdentifier: String?, val bankIdentifier: String?,

View File

@ -1,7 +1,7 @@
package net.codinux.banking.ui.model package net.codinux.banking.ui.model
import net.codinux.banking.client.model.Amount import net.codinux.banking.client.model.Amount
import net.codinux.banking.persistence.entities.BankAccountEntity import net.codinux.banking.dataaccess.entities.BankAccountEntity
data class ShowTransferMoneyDialogData( data class ShowTransferMoneyDialogData(
val senderAccount: BankAccountEntity? = null, val senderAccount: BankAccountEntity? = null,

View File

@ -1,4 +1,4 @@
package net.codinux.banking.ui.model.settings package net.codinux.banking.ui.model
enum class TransactionsGrouping { enum class TransactionsGrouping {
None, None,

View File

@ -5,11 +5,5 @@ enum class ErroneousAction {
UpdateAccountTransactions, UpdateAccountTransactions,
TransferMoney, TransferMoney
ReadEpcQrCode,
SaveToDatabase,
BiometricAuthentication
} }

View File

@ -1,161 +0,0 @@
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)
}
}
}
}
}
}

View File

@ -1,100 +0,0 @@
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")
}
}
}
}
}

View File

@ -1,150 +0,0 @@
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)
}
}
}
}
}

View File

@ -1,186 +0,0 @@
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")
}
}
}

View File

@ -1,5 +1,6 @@
package net.codinux.banking.ui.screens package net.codinux.banking.ui.screens
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text import androidx.compose.material.Text
@ -13,18 +14,12 @@ import androidx.compose.ui.text.style.TextAlign
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.codinux.banking.persistence.entities.AccountTransactionEntity import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.ui.IOorDefault 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.Colors
import net.codinux.banking.ui.config.DI 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 import net.codinux.banking.ui.service.BankDataImporterAndExporter
private const val iOSMaxDisplayedDataLength = 20_000
@Composable @Composable
fun ExportScreen(onClosed: () -> Unit) { fun ExportScreen(onClosed: () -> Unit) {
var transactions: Collection<AccountTransactionEntity> var transactions: Collection<AccountTransactionEntity>
@ -33,8 +28,6 @@ fun ExportScreen(onClosed: () -> Unit) {
var exportedDataText by remember { mutableStateOf("") } var exportedDataText by remember { mutableStateOf("") }
var exportedDataTextToDisplay by remember { mutableStateOf("") }
val importerExporter = BankDataImporterAndExporter() val importerExporter = BankDataImporterAndExporter()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
@ -48,15 +41,12 @@ fun ExportScreen(onClosed: () -> Unit) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
exportedDataText = initiallyExportedData 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 isLoadingExportedData = false
} }
} }
FullscreenViewBase("Umsätze exportieren", onClosed = onClosed) { FullscreenViewBase("Umsätze exportieren", onClosed = onClosed) {
Column(Modifier.fillMaxWidth()) { Column {
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:") 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) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
@ -65,7 +55,13 @@ fun ExportScreen(onClosed: () -> Unit) {
} }
} }
if (isLoadingExportedData) { if (isLoadingExportedData == false) {
Column(Modifier.verticalScroll(ScrollState(0), enabled = true).horizontalScroll(ScrollState(0), enabled = true)) {
SelectionContainer {
Text(exportedDataText, fontFamily = FontFamily.Monospace)
}
}
} else {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
@ -75,12 +71,6 @@ fun ExportScreen(onClosed: () -> Unit) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
} else {
Column(Modifier.verticalScroll().horizontalScroll()) {
SelectionContainer(modifier = Modifier.fillMaxSize()) {
Text(exportedDataTextToDisplay, fontFamily = FontFamily.Monospace)
}
}
} }
} }
} }

View File

@ -1,103 +0,0 @@
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)
}
}
}
}
}
}

View File

@ -3,8 +3,9 @@ package net.codinux.banking.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* 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.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -12,23 +13,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex 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.composables.text.HeaderText
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style
import net.codinux.banking.ui.extensions.applyPlatformSpecificPadding
@Composable @Composable
fun FullscreenViewBase( fun FullscreenViewBase(
title: String, title: String,
confirmButtonTitle: String = "OK", confirmButtonTitle: String = "OK",
confirmButtonEnabled: Boolean = true, confirmButtonEnabled: Boolean = true,
dismissButtonTitle: String = "Abbrechen",
showDismissButton: Boolean = false,
showButtonBar: Boolean = true,
onConfirm: (() -> Unit)? = null,
onClosed: () -> Unit, onClosed: () -> Unit,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
@ -36,13 +29,15 @@ fun FullscreenViewBase(
onClosed, onClosed,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
) { ) {
Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).applyPlatformSpecificPadding().padding(horizontal = 12.dp)) { Column(Modifier.fillMaxSize().zIndex(1000f).background(Color.White).padding(8.dp)) {
Row(Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp).height(32.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth()) {
HeaderText(title, Modifier.weight(1f), textColor = Style.ListItemHeaderTextColor) HeaderText(title, Modifier.padding(top = 8.dp, bottom = 16.dp).weight(1f))
if (DI.platform.type != PlatformType.Android) { // for iOS it's also relevant due to the missing back gesture / back button if (DI.platform.isDesktop) {
CloseButton(onClick = onClosed) TextButton(onClosed, colors = ButtonDefaults.buttonColors(contentColor = Colors.Zinc700, backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.Close, contentDescription = "Close dialog", Modifier.size(32.dp))
}
} }
} }
@ -50,25 +45,21 @@ fun FullscreenViewBase(
content() content()
} }
if (showButtonBar) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
if (showDismissButton) { // TextButton(onClick = onClosed, Modifier.width(Style.DialogButtonWidth)) {
TextButton(onClick = onClosed, Modifier.weight(1f)) { // Text("Abbrechen", color = Colors.CodinuxSecondaryColor)
Text(dismissButtonTitle, color = Colors.CodinuxSecondaryColor) // }
} //
// Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(8.dp))
}
TextButton( TextButton(
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
enabled = confirmButtonEnabled, enabled = confirmButtonEnabled,
onClick = { onConfirm?.invoke(); onClosed() } onClick = { /* onConfirm?.invoke() ?: */ onClosed() }
) { ) {
Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center) Text(confirmButtonTitle, color = Colors.CodinuxSecondaryColor, textAlign = TextAlign.Center)
} }
} }
} }
} }
}
} }

View File

@ -1,114 +0,0 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
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.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import bankmeister.composeapp.generated.resources.*
import bankmeister.composeapp.generated.resources.Res
import net.codinux.banking.ui.composables.authentification.BiometricAuthenticationButton
import net.codinux.banking.ui.forms.PasswordTextField
import net.codinux.banking.ui.model.AuthenticationResult
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.service.AuthenticationService
import net.codinux.banking.ui.service.safelyAuthenticateWithBiometrics
import org.jetbrains.compose.resources.imageResource
@Composable
fun LoginScreen(appSettings: AppSettings, onLoginSuccess: () -> Unit) {
var password by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
fun successfullyLoggedIn() {
onLoginSuccess()
}
fun checkPassword() {
if (appSettings.hashedPassword != null && AuthenticationService.checkPassword(password, appSettings.hashedPassword!!)) {
successfullyLoggedIn()
} else {
showError = true
}
}
fun checkBiometricLoginResult(result: AuthenticationResult) {
if (result.successful) {
successfullyLoggedIn()
} else {
showError = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(imageResource(Res.drawable.AppIcon_round), "Bankmeister's app icon", Modifier.size(144.dp).padding(bottom = 32.dp))
if (appSettings.authenticationMethod == AppAuthenticationMethod.Password) {
Text("Bitte geben Sie Ihr Passwort ein um die App zu entsperren", style = MaterialTheme.typography.h5, textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(24.dp))
PasswordTextField(
password = password,
onEnterPressed = { checkPassword() },
isError = showError
) {
password = it
showError = false
}
if (showError) {
Spacer(modifier = Modifier.height(16.dp))
Text("Passwort ist falsch", color = MaterialTheme.colors.error, fontSize = 18.sp)
}
Button(modifier = Modifier.padding(top = 24.dp).width(300.dp).height(50.dp), onClick = { checkPassword() }) {
Text("Login", color = Color.White)
}
}
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
if (showError) {
Text("Biometrische Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.", color = MaterialTheme.colors.error, fontSize = 18.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).padding(horizontal = 16.dp))
}
BiometricAuthenticationButton {
checkBiometricLoginResult(it)
}
}
}
}
LaunchedEffect(appSettings.authenticationMethod) {
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
AuthenticationService.safelyAuthenticateWithBiometrics { result ->
checkBiometricLoginResult(result)
}
}
}
}

View File

@ -6,7 +6,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -31,8 +30,6 @@ fun MainScreen() {
val fabPositionAdjustment = if (isMobile) 44.dp // FabSpacing = 16.dp + FAB height (= 56.dp) / 2 val fabPositionAdjustment = if (isMobile) 44.dp // FabSpacing = 16.dp + FAB height (= 56.dp) / 2
else (-10).dp else (-10).dp
var showFloatingActionMenu by remember { mutableStateOf(false) }
val desktopDrawerWidth = 350.dp val desktopDrawerWidth = 350.dp
val uiState = DI.uiState val uiState = DI.uiState
@ -54,13 +51,9 @@ fun MainScreen() {
FloatingActionButton( FloatingActionButton(
shape = CircleShape, shape = CircleShape,
modifier = Modifier.offset(x = 4.dp, y = fabPositionAdjustment), modifier = Modifier.offset(x = 4.dp, y = fabPositionAdjustment),
onClick = { showFloatingActionMenu = !showFloatingActionMenu } onClick = { uiState.showAddAccountDialog.value = true }
) { ) {
if (showFloatingActionMenu) { Icon(Icons.Filled.Add, contentDescription = "Add a bank account")
Icon(Icons.Filled.Close, contentDescription = "Menü zum Hinzufügen eines Kontos, für eine neue Überweisung, ... verstecken")
} else {
Icon(Icons.Filled.Add, contentDescription = "Zeigt ein Menü zum Hinzufügen eines Kontos, für eine neue Überweisung, ... an")
}
} }
}, },
drawerContent = { if (isMobile) SideMenuContent() else null }, drawerContent = { if (isMobile) SideMenuContent() else null },
@ -92,9 +85,6 @@ fun MainScreen() {
} }
} }
FloatingActionMenu(showFloatingActionMenu) { showFloatingActionMenu = false }
if (showFilterBar.value) { if (showFilterBar.value) {
FilterBar() FilterBar()
} }

View File

@ -1,127 +0,0 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
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.ui.composables.authentification.BiometricAuthenticationButton
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.extensions.ImeDone
import net.codinux.banking.ui.extensions.ImeNext
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.PasswordTextField
import net.codinux.banking.ui.forms.SegmentedControl
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.service.AuthenticationService
private val buttonHeight = 50.dp
@Composable
fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
val currentAuthenticationMethod = appSettings.authenticationMethod
val isBiometricAuthenticationSupported = AuthenticationService.supportsBiometricAuthentication
val supportedAuthenticationMethods = buildList {
add(AppAuthenticationMethod.Password)
if (isBiometricAuthenticationSupported) {
add(AppAuthenticationMethod.Biometric)
}
add(AppAuthenticationMethod.None)
}
var selectedAuthenticationMethod by remember { mutableStateOf(if (appSettings.authenticationMethod == AppAuthenticationMethod.None) AppAuthenticationMethod.Password else appSettings.authenticationMethod) }
var newPassword by remember { mutableStateOf("") }
var confirmedNewPassword by remember { mutableStateOf("") }
var hasAuthenticatedWithBiometric by remember { mutableStateOf(false) }
val isRequiredDataEntered by remember(newPassword, confirmedNewPassword) {
derivedStateOf {
(selectedAuthenticationMethod == AppAuthenticationMethod.Password && newPassword.isNotBlank() && newPassword == confirmedNewPassword)
|| (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric && hasAuthenticatedWithBiometric)
}
}
val coroutineScope = rememberCoroutineScope()
fun saveNewAppProtection() {
coroutineScope.launch {
appSettings.authenticationMethod = selectedAuthenticationMethod
appSettings.hashedPassword = if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) AuthenticationService.hashPassword(newPassword)
else null
DI.bankingService.saveAppSettings(appSettings)
onClosed()
}
}
FullscreenViewBase("Appzugang schützen", showButtonBar = false, onClosed = onClosed) {
Column(Modifier.fillMaxSize().padding(bottom = 8.dp)) {
SegmentedControl(supportedAuthenticationMethods, selectedAuthenticationMethod, Modifier.padding(bottom = 20.dp), getOptionDisplayText = { Internationalization.translate(it) }) {
selectedAuthenticationMethod = it
}
Column(Modifier.weight(1f).verticalScroll()) {
Spacer(Modifier.weight(1f))
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
if (currentAuthenticationMethod == AppAuthenticationMethod.None) {
Text("Appzugang ist bereits ungeschützt", fontSize = 18.sp, textAlign = TextAlign.Center)
} else {
Text("Möchten Sie den Appzugangsschutz wirklich entfernen?", fontSize = 18.sp, textAlign = TextAlign.Center)
}
}
}
if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) {
PasswordTextField(newPassword, "Neues Password", keyboardOptions = KeyboardOptions.ImeNext) { newPassword = it }
PasswordTextField(confirmedNewPassword, "Password bestätigen", Modifier.padding(top = 16.dp), keyboardOptions = KeyboardOptions.ImeDone) { confirmedNewPassword = it }
}
if (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric) {
BiometricAuthenticationButton { result ->
hasAuthenticatedWithBiometric = result.successful
}
}
Spacer(Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = currentAuthenticationMethod != AppAuthenticationMethod.None,
colors = ButtonDefaults.buttonColors(Colors.DestructiveColor), onClick = { saveNewAppProtection() }) {
Text("Appzugangsschutz entfernen", color = Color.White)
}
} else {
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = isRequiredDataEntered,
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { saveNewAppProtection() }) {
Text("Setzen", color = Color.White)
}
}
}
}
}
}

View File

@ -1,30 +0,0 @@
package net.codinux.banking.ui.screens
import androidx.compose.runtime.Composable
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.Config.NewLine
import net.codinux.banking.ui.model.error.ErroneousAction
import net.codinux.banking.ui.service.QrCodeService
@Composable
fun TransferMoneyFromQrCodeScreen(onClosed: () -> Unit) {
if (QrCodeService.supportsReadingQrCodesFromCamera) {
FullscreenViewBase("Überweisungsdaten aus QR Code lesen", "Abbrechen", onClosed = onClosed) {
QrCodeService.readQrCodeFromCamera { result ->
onClosed()
if (result.decodedQrCodeText != null) {
val decodingResult = DI.epcQrCodeService.decode(result.decodedQrCodeText)
if (decodingResult.data != null) {
DI.uiState.showTransferMoneyDialogData.value = decodingResult.data
} else if (decodingResult.error != null) {
DI.uiState.applicationErrorOccurred(ErroneousAction.ReadEpcQrCode, decodingResult.error + "${NewLine}${NewLine}Gelesener QR-Code ist:${NewLine}${result.decodedQrCodeText}")
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More