Compare commits

...

152 Commits

Author SHA1 Message Date
dankito ed67f2c772 Released Android version 21 2024-10-20 11:24:02 +02:00
dankito 62b750c4f3 Using now TanMethodTypesToMigrate from BankingClient 2024-10-20 11:23:54 +02:00
dankito 395c29a63d Updated BankingClient to version 0.7.2 2024-10-20 11:23:25 +02:00
dankito 91ea593fe1 Updated to new BankFinder package net.codinux 2024-10-20 11:23:08 +02:00
dankito 81a98af43f Released Android version 20 2024-10-19 21:42:17 +02:00
dankito 04e78b042e Displaying also BIC 2024-10-18 06:53:30 +02:00
dankito 0c87d99d77 Using now BankFinder library 2024-10-18 06:48:47 +02:00
dankito b5116604c1 Fixed loading the renamed TanMethodTypes 2024-10-18 05:34:02 +02:00
dankito 98f15d3a8d Released Android version 19 2024-10-17 23:37:28 +02:00
dankito b7cb22e7a8 Updated to new typing of BankingClient 0.7.1 2024-10-17 23:36:57 +02:00
dankito 6af5ef2529 Updated BankingClient version to 0.7.1; implemented passing preferredTanMethods to BankingClient 2024-10-17 23:30:01 +02:00
dankito 350a18c2a3 Set Android versionCode to 18 (but released app with this version code two commits ago) 2024-10-17 23:27:02 +02:00
dankito 2c32c1970c Added migrating Holding.quantity data type 2024-10-17 23:26:15 +02:00
dankito a5b4540443 Implemented saving Image (and FlickerCode) settings 2024-10-17 23:01:11 +02:00
dankito 166219d7e3 Added method setSchemaVersion() 2024-10-16 19:35:28 +02:00
dankito 5af6cc82fc Extracted ImageView 2024-10-16 18:25:47 +02:00
dankito d30337eef2 Set iOS build version to 12 2024-10-16 02:25:10 +02:00
dankito bf81a9854d Raised Gradle heap space to 4g as otherwise iOS build fails with out of memory exception 2024-10-16 02:24:43 +02:00
dankito 5965a1e4dd Showing now as version 1.0.0 Alpha 15 2024-10-16 01:55:55 +02:00
dankito e16d71a1fa Updated BankingClient version to 0.7.0 and EpcQrCode version to 0.5.0 2024-10-16 01:54:47 +02:00
dankito f87375b8dd Added hint that this screen is under construction 2024-10-16 01:47:30 +02:00
dankito 43f15fa662 Fixed remembering vertical scroll state 2024-10-15 22:19:11 +02:00
dankito 1c7fa49de5 Updated to new data model that their is now BankAccess.clientData and serializedClientData 2024-10-15 19:48:56 +02:00
dankito f547b76c7b Implemented selecting account for which to show MessageLog 2024-10-15 12:59:11 +02:00
dankito 0a46dd931f Added hint how to use EPC QR code 2024-10-15 11:18:31 +02:00
dankito 39c9006809 Showing serverTransactionsRetentionDays and lastAccountUpdateTime to user 2024-10-15 11:17:46 +02:00
dankito 8324ac0101 Better labels for EPC QR code actions 2024-10-15 11:15:24 +02:00
dankito 3a40167e2f Using now new constructor 2024-10-15 11:14:46 +02:00
dankito c3655e38ea Updated to new data model, added bank and account display name 2024-10-15 11:14:16 +02:00
dankito bca359d4ed Fixed that .toDouble() had been removed 2024-10-15 10:26:09 +02:00
dankito fe3a97377f Showing MessageLog in FeedbackScreen 2024-10-15 10:18:40 +02:00
dankito 9c9d52f03e Collecting MessageLog 2024-10-15 10:17:22 +02:00
dankito b518f4c0ee Extracted formatQuantity() 2024-10-15 10:14:55 +02:00
dankito fe853f03e9 Updated that Holding.quantity is of type Double, not Int 2024-10-15 10:13:01 +02:00
dankito 02d8d14ede Showing action in bold 2024-10-15 01:39:40 +02:00
dankito 04fa2dcbb4 Saving clientData in db 2024-10-14 22:44:35 +02:00
dankito e0e150e53a Retrieving account transactions in parallel not serially 2024-10-14 22:30:11 +02:00
dankito 54ca55e2f9 Set showTransactionsInAlternatingColors to false by default 2024-10-04 20:55:40 +02:00
dankito 54e9a70122 Added check if Android device has a camera 2024-10-04 16:50:41 +02:00
dankito 74d42abce3 Set Android versionCode to 17 2024-10-04 09:52:04 +02:00
dankito 41a6bef7d2 Removed "Neue Überweisung" from SideMenu 2024-10-04 09:36:34 +02:00
dankito 8c24b83ecf Raised spacing between balance and settings icon and reduces padding to right margin 2024-10-04 09:35:27 +02:00
dankito 466ab84c36 Implemented also showing total amount in BanksList 2024-10-04 09:32:14 +02:00
dankito 12c9becd17 Set showColoredAmounts by default to true again 2024-10-04 09:14:07 +02:00
dankito 390d529be0 Fixed updating account data after retrieving transactions 2024-10-04 09:13:39 +02:00
dankito cd8f8a32e6 If accounts are added showing "Alle Konten" instead of "Bankmeister" 2024-10-04 09:06:24 +02:00
dankito a532130fcb On click on title also toggling drawer state 2024-10-04 09:06:00 +02:00
dankito 08d6e62a38 Updated Android versionCode to 16 2024-10-04 08:07:00 +02:00
dankito a2d752aca1 Displaying max allowed characters 2024-10-04 07:45:06 +02:00
dankito a58bd1d2ce In case of error showing QR code text 2024-10-04 07:37:26 +02:00
dankito 052ee9c7e5 Implemented pinch to zoom for reading EPC QR Codes 2024-10-04 07:32:48 +02:00
dankito 1228b6884d Extracted setupCameraView() 2024-10-04 07:07:05 +02:00
dankito 1ed96fce7d Removed checking for charset, it seems to work without 2024-10-04 07:05:04 +02:00
dankito e4a8a79ee3 Implemented saveing updated account properties in db after retrieving transactions 2024-10-04 06:36:07 +02:00
dankito 20fdc8dece Fixed that due to added bankId to identifier comparison for new account transactions didn't work anymore (as the transactions returned from banking client of course don't have a database id yet) 2024-10-04 05:50:12 +02:00
dankito f107d947ff Using now AndroidContext.applicationContext in ImageService.android 2024-10-04 05:48:52 +02:00
dankito e1bb7722ff Implemented asking for Camera permission 2024-10-04 05:48:11 +02:00
dankito 316a0027f7 Removed unused dependencies and updated versions 2024-10-04 05:46:51 +02:00
dankito d47bc46cf8 Implemented decoding EPC QR Code on Android 2024-10-04 05:44:46 +02:00
dankito fbd9c9485a Going to amount or reference text field if other data are set in ShowTransferMoneyDialogData 2024-10-04 01:28:19 +02:00
dankito fd53b2f005 Fixed resetting epcQrCodeGeneratingError (otherwise after first error always epcQrCodeGeneratingError would be displayed) 2024-10-04 01:27:42 +02:00
dankito 5d00bbf77e Only showing account selection box if there are any SEPA accounts 2024-10-04 01:26:10 +02:00
dankito c89220bc0c Implemented catching and displaying EPC QR Code generation errors 2024-10-03 21:42:45 +02:00
dankito e3f9c78b95 Added informationForUser, but not exposing it to user as it may causes overflow exception of used QR code library 2024-10-03 21:33:58 +02:00
dankito 1520d19625 Implemented selecting account for which EPC QR code should get generated 2024-10-03 21:32:37 +02:00
dankito 3d474f38ae Implemented generating EQP QR Code 2024-10-03 21:18:59 +02:00
dankito 737d35b9a6 Set Android versionCode to 15 2024-09-27 03:45:45 +02:00
dankito 315e05b08e Set Android versionCode to 14 2024-09-27 03:39:11 +02:00
dankito b802f5b48f Configured OS specific directories for user data 2024-09-27 03:31:49 +02:00
dankito 0dac13dc43 Fixed that signatures of third party libraries get copied to uber jar refusing java -jar to run the uber jar 2024-09-27 01:09:02 +02:00
dankito 802bab9c38 Removed Rpm package type and set desktop version to 0.9.0 (except for DMG where major version < 1 are not allowed) 2024-09-27 00:16:29 +02:00
dankito d12cb7269b Fixed using Composable's scope instead of GlobalScope 2024-09-27 00:12:35 +02:00
dankito e3a6cd7df2 Using now properties from new BankingModel 2024-09-26 14:27:38 +02:00
dankito 359f453543 Updated to new data model, that mimeType, imageBytesBase64 and parsedDataSet can be null 2024-09-26 14:15:59 +02:00
dankito 14cb9c789c Implemented showing decoding error 2024-09-26 13:54:27 +02:00
dankito b805a070eb Implemented changing flicker code frequency 2024-09-26 13:08:26 +02:00
dankito 3f5527a0fd Updated BankingModel to version 0.6.2-SNAPSHOT 2024-09-26 12:41:42 +02:00
dankito 36d5e0a36a Set Android versionCode to 13 (was actually already before previous commit) 2024-09-26 12:41:22 +02:00
dankito ebbdd56418 Implemented displaying FlickerCodes 2024-09-26 12:40:42 +02:00
dankito 4cdc573364 Bumped iOS version to 11 2024-09-26 06:49:58 +02:00
dankito 0ac9c7155d Raised min text length for search to make it possible to hide autocomplete (TransferMoneyDialog is very buggy on iOS) 2024-09-26 06:39:23 +02:00
dankito db2a75fba7 Displaying count entered of max allowed recipient name length 2024-09-26 06:38:35 +02:00
dankito 2ba1b52a80 Passing onEnterPressed on to TextField 2024-09-26 06:37:54 +02:00
dankito 1970eff09a Cut displayed text on iOS as otherwise view crashes (displays only a white screen then) 2024-09-26 06:37:30 +02:00
dankito e896fbb3cc Made whole dialog content scrollable 2024-09-26 05:52:49 +02:00
dankito 3259c079b4 Fixed dialogs padding 2024-09-26 05:52:16 +02:00
dankito ba8b475eaf Extracted KeyboardOptions.ImeNext and KeyboardOptions.ImeDone 2024-09-26 05:19:07 +02:00
dankito 0aa25e0c59 Fixed that on iOS and macOS text shadow has been displayed really ugly 2024-09-26 04:53:42 +02:00
dankito 5ac65308bb Fixed that entering text for amount or reference didn't reduce the autocomplete suggestions 2024-09-26 04:52:46 +02:00
dankito ee3faa7de1 Fixed at least a bit that on Android autocomplete dropdown for reference covers soft keyboard 2024-09-26 04:51:40 +02:00
dankito 3eb9c9fd95 Changed dialog title to "Neue Überweisung" 2024-09-26 04:50:17 +02:00
dankito b13be27a2d Fixed that by default "null" has been displayed for amount 2024-09-26 04:49:53 +02:00
dankito 6ac2faf207 Added button to close FilterBar 2024-09-26 04:44:27 +02:00
dankito 51fe6d621d Added message that changing the TAN medium is currently not implemented yet 2024-09-26 04:13:08 +02:00
dankito 4fbc52542d Fixed that on iOS system and navigation bar covered parts of the FullscreenViews 2024-09-24 08:11:22 +02:00
dankito 0a0b93f9c8 Implemented biometric authentication on iOS (at least i hope it works, cannot test it) 2024-09-24 05:55:47 +02:00
dankito a47f580594 Updated Android Gradle plugin to 8.6.0 2024-09-24 05:26:22 +02:00
dankito 74e144de59 Implemented hashing passwords on iOS 2024-09-24 05:25:57 +02:00
dankito 2809a4b149 Removed unused imports 2024-09-24 02:49:19 +02:00
dankito 859a9d6b7a Implemented show floating action menu with options Add account and New money transfer 2024-09-24 02:50:02 +02:00
dankito 18ea0e35f1 Implemented updating AccountTransaction properties 2024-09-24 01:00:19 +02:00
dankito bbfc591e5b Fixed new property name 2024-09-24 00:29:19 +02:00
dankito 4531380bac Added keys for holdings and transaction groups 2024-09-23 23:35:52 +02:00
dankito c6f4b6d250 Implemented telling user when saving to db failed 2024-09-23 23:30:11 +02:00
dankito 5a0ade46b2 Implemented updating BankAccount properties 2024-09-23 23:26:07 +02:00
dankito 4d7cca7a7e Closing FilterBar on Android back button press or Escape press 2024-09-23 21:48:08 +02:00
dankito 97282adf12 Fixed that clientData and userSetDisplayName have been mixed up 2024-09-23 21:46:43 +02:00
dankito 3c2eb3d4d7 Implemented updating BankAccess properties 2024-09-23 21:46:20 +02:00
dankito a88ddbdd16 Fixed centering FormListItem text 2024-09-20 12:13:41 +02:00
dankito b86c59ef24 Fixed typo and centering text 2024-09-20 12:10:24 +02:00
dankito 33b93c170f Made CircularProgressIndicator size better fitting into button bar (CircularProgressIndicator default size: 40 dp) 2024-09-20 12:10:06 +02:00
dankito 78edbd6d72 Implemented deleting bank access 2024-09-20 12:07:43 +02:00
dankito f365bfd883 Added composeApp/release/ to .gitignore 2024-09-20 05:58:01 +02:00
dankito c6f93e1d85 Bumped Android version to 12 / 1.0.0-Alpha-14 2024-09-20 05:57:26 +02:00
dankito af3dd02509 Configured distribution settings for Windows / MSI 2024-09-20 05:56:52 +02:00
dankito 3b72d95234 Fixed order 2024-09-20 05:51:57 +02:00
dankito abc9ceb29e Fixed determining if folder is writable 2024-09-20 05:49:42 +02:00
dankito b5dbf92b9b Created AppIcons and configured native distributions 2024-09-20 04:16:45 +02:00
dankito d549b96e7b Ensuring BankingRepository / SqliteDriver gets created only once and catching errors 2024-09-20 02:46:37 +02:00
dankito b384f6bc00 Also configured database and image cache that for releases they get written to user's home dir (which is important for desktop app bundles 2024-09-20 02:43:23 +02:00
dankito 5d0669c5fe Fixed that on releases (which is important for desktop app bundles) logs get written to user's home dir 2024-09-20 02:41:16 +02:00
dankito 6a8b913bc4 Fixed log file timestamp 2024-09-20 02:39:41 +02:00
dankito 7d9a2695a9 Updated klf version to 1.6.2 2024-09-20 02:35:43 +02:00
dankito 7ce76d73ea Moved setting BankingRepository to App() 2024-09-20 00:35:37 +02:00
dankito 1f19da85f3 Renamed module / framework to Bankmeister 2024-09-19 23:25:31 +02:00
dankito 8707c5e3d7 Configured iOS app for AppStore distribution and uploaded version 10 to TestFlight 2024-09-19 22:43:27 +02:00
dankito ff8bf80f6d Fixed text color of balance if showColoredAmounts is false 2024-09-19 21:47:40 +02:00
dankito df093d0cd3 Updated BankingClient version to 0.6.1 2024-09-19 21:38:30 +02:00
dankito 08e3096892 Set option to fetch all transactions to true by default 2024-09-19 21:35:29 +02:00
dankito 931d41d610 By default not showing amounts colored anymore 2024-09-19 21:33:57 +02:00
dankito fc0d2642e5 Applying default hierarchy template 2024-09-19 19:10:07 +02:00
dankito 7712102af2 Suppressed EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING compiler warning 2024-09-19 19:09:44 +02:00
dankito 6564a9d33d Moved now all Sqldelight related classes and settings over to BankingPersistence - and finally it compiles on iOS! 2024-09-19 19:02:16 +02:00
dankito 0f89314ba3 Started to extract persistence library which contains all Sqldelight specific code as Sqldelight conflicts with Compose on iOS 2024-09-19 17:13:49 +02:00
dankito 4fa7adeeb1 Added implementations for iOS 2024-09-19 04:18:35 +02:00
dankito f5a93bdddd Using sorted methods from new BankingClient version 2024-09-19 02:51:53 +02:00
dankito 4a5748b813 Updated BankingClient to version 0.6.1-SNAPSHOT 2024-09-19 02:50:50 +02:00
dankito d447f2991c Fixed bug that whitespace at begin or end was not ignored, leading to missing search results 2024-09-19 01:50:47 +02:00
dankito ba156a8512 Showing an error message if biometric authentication fails 2024-09-19 01:29:24 +02:00
dankito 41c2b89c34 Added negativeButtonText, which crashes when DEVICE_CREDENTIALS is set, and which crashes if missing and DEVICE_CREDENTIALS is not set 2024-09-19 01:07:22 +02:00
dankito 607eb4c2f5 Bumped Android version to 11 / 1.0.0-Alpha-13 2024-09-18 17:53:16 +02:00
dankito 9412f6b7f0 Implemented biometric authentication on Android 2024-09-18 17:06:39 +02:00
dankito 4697119c58 Implemented LoginScreen 2024-09-18 06:07:57 +02:00
dankito 6e6449e956 Implemented ProtectAppSettingsDialog 2024-09-18 05:45:07 +02:00
dankito f1c4c8ca13 For FullscreenViews using now dark gray as header text color 2024-09-18 02:37:09 +02:00
dankito a50f55daff Extracted SelectableFormListItem 2024-09-18 02:28:12 +02:00
dankito db8d4a7dcd Started BankAccountSettingsScreen, but it's not possible to save changes yet 2024-09-18 02:24:04 +02:00
dankito 2813224eff Started BankSettingsScreen, but it's not possible to save changes yet or to delete account 2024-09-18 01:42:31 +02:00
dankito d98a77bc1d Showing also transaction (direct debit) details 2024-09-18 00:19:22 +02:00
dankito 372a259f8b Updated version to Alpha 13 2024-09-17 23:52:05 +02:00
dankito da9184edea Started AccountTransactionDetailsScreen 2024-09-17 23:50:45 +02:00
dankito ba3d0c4d30 Inverted order 2024-09-17 22:51:28 +02:00
162 changed files with 4598 additions and 674 deletions

3
.gitignore vendored
View File

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

View File

@ -0,0 +1,114 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.sqldelight)
}
kotlin {
jvmToolchain(11)
jvm()
js {
moduleName = "BankingPersistence"
binaries.executable()
browser()
}
androidTarget {
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "BankingPersistence"
isStatic = true
}
}
applyDefaultHierarchyTemplate()
sourceSets {
commonMain.dependencies {
implementation(libs.banking.client.model)
implementation(libs.fints4k.banking.client)
implementation(libs.kotlinx.datetime)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines.extensions)
implementation(libs.sqldelight.paging.extensions)
implementation(libs.klf)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.coroutines.test)
}
jvmMain.dependencies {
implementation(libs.sqldelight.sqlite.driver)
}
jvmTest.dependencies {
implementation(libs.kotlin.test.junit)
}
androidMain.dependencies {
implementation(libs.sqldelight.android.driver)
}
iosMain.dependencies {
implementation(libs.sqldelight.native.driver)
}
}
}
sqldelight {
databases {
create("BankmeisterDb") {
packageName.set("net.codinux.banking.persistence")
generateAsync = true
schemaOutputDirectory = file("src/commonMain/sqldelight/databases")
}
}
}
android {
namespace = "net.codinux.banking.persistence"
compileSdk = libs.versions.android.compileSdk.get().toInt()
// sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
// proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}

View File

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

View File

@ -0,0 +1,10 @@
package net.codinux.banking.persistence
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver =
AndroidSqliteDriver(schema.synchronous(), AndroidContext.applicationContext, dbName)

View File

@ -0,0 +1,69 @@
package net.codinux.banking.persistence
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.BankAccess
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.persistence.entities.AccountTransactionEntity
import net.codinux.banking.persistence.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.HoldingEntity
import net.codinux.banking.persistence.entities.BankAccessEntity
import net.codinux.banking.persistence.entities.UiSettingsEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.model.settings.ImageSettings
interface BankingRepository {
fun getAppSettings(): AppSettings?
suspend fun saveAppSettings(settings: AppSettings)
fun getUiSettings(): UiSettingsEntity?
suspend fun saveUiSettings(settings: UiSettingsEntity)
fun getImageSettings(id: String): ImageSettings?
suspend fun saveImageSettings(settings: ImageSettings)
fun getAllBanks(): List<BankAccessEntity>
suspend fun persistBank(bank: BankAccess): BankAccessEntity
suspend fun updateBank(bank: BankAccessEntity, loginName: String, password: String, bankName: String?)
suspend fun updateBank(bank: BankAccessEntity, serializedClientData: String?)
suspend fun updateAccount(account: BankAccountEntity, userSetDisplayName: String?, hideAccount: Boolean, includeInAutomaticAccountsUpdate: Boolean)
suspend fun updateAccount(account: BankAccountEntity, balance: Amount, lastAccountUpdateTime: Instant, retrievedTransactionsFrom: LocalDate?)
suspend fun deleteBank(bank: BankAccessEntity)
suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity>
suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity>
suspend fun updateHoldings(holdings: List<HoldingEntity>)
suspend fun deleteHoldings(holdings: List<HoldingEntity>)
fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel>
fun getAllAccountTransactions(): List<AccountTransactionEntity>
fun getAllTransactionsForBank(bank: BankAccessEntity): List<AccountTransactionEntity>
fun getTransactionById(transactionId: Long): AccountTransactionEntity?
suspend fun updateTransaction(transaction: AccountTransactionEntity, userSetOtherPartyName: String?, userSetReference: String?, notes: String?)
}

View File

@ -1,15 +1,20 @@
package net.codinux.banking.dataaccess
package net.codinux.banking.persistence
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.BankAccess
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.persistence.entities.AccountTransactionEntity
import net.codinux.banking.persistence.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.HoldingEntity
import net.codinux.banking.persistence.entities.BankAccessEntity
import net.codinux.banking.persistence.entities.UiSettingsEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.settings.UiSettings
import net.codinux.banking.ui.model.settings.ImageSettings
import net.codinux.banking.ui.model.settings.TransactionsGrouping
class InMemoryBankingRepository(
banks: Collection<BankAccess> = emptyList(),
@ -23,7 +28,9 @@ class InMemoryBankingRepository(
private val transactions = transactions.map { map(it) }.toMutableList()
private lateinit var uiSettings: UiSettings
private var uiSettings: UiSettingsEntity = UiSettingsEntity(true, TransactionsGrouping.Month, true, true, true)
private var imageSettings = mutableMapOf<String, ImageSettings>()
override fun getAppSettings(): AppSettings? = appSettings
@ -32,12 +39,17 @@ class InMemoryBankingRepository(
this.appSettings = settings
}
override fun getUiSettings(settings: UiSettings) {
override fun getUiSettings() = this.uiSettings
override suspend fun saveUiSettings(settings: UiSettingsEntity) {
this.uiSettings = settings
}
override suspend fun saveUiSettings(settings: UiSettings) {
this.uiSettings = settings
override fun getImageSettings(id: String) = imageSettings[id]
override suspend fun saveImageSettings(settings: ImageSettings) {
imageSettings[settings.id] = settings
}
@ -49,6 +61,27 @@ class InMemoryBankingRepository(
return entity
}
override suspend fun updateBank(bank: BankAccessEntity, loginName: String, password: String, bankName: String?) {
// no-op
}
override suspend fun updateBank(bank: BankAccessEntity, serializedClientData: String?) {
// no-op
}
override suspend fun updateAccount(account: BankAccountEntity, userSetDisplayName: String?, hideAccount: Boolean, includeInAutomaticAccountsUpdate: Boolean) {
// no-op
}
override suspend fun updateAccount(account: BankAccountEntity, balance: Amount, lastAccountUpdateTime: Instant, retrievedTransactionsFrom: LocalDate?) {
// no-op
}
override suspend fun deleteBank(bank: BankAccessEntity) {
this.banks.remove(bank)
}
override suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity> {
throw NotImplementedError("Lazy developer, method is not implemented")
}
@ -76,6 +109,10 @@ class InMemoryBankingRepository(
override fun getTransactionById(transactionId: Long): AccountTransactionEntity? =
getAllAccountTransactions().firstOrNull { it.id == transactionId }
override suspend fun updateTransaction(transaction: AccountTransactionEntity, userSetOtherPartyName: String?, userSetReference: String?, notes: String?) {
// no-op
}
private fun map(bank: BankAccess) = BankAccessEntity(
nextId++,

View File

@ -1,25 +1,53 @@
package net.codinux.banking.dataaccess
package net.codinux.banking.persistence
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.BankAccess
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.BankAccountFeatures
import net.codinux.banking.client.model.BankAccountType
import net.codinux.banking.client.model.BankingGroup
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.client.model.tan.*
import net.codinux.banking.dataaccess.entities.*
import net.codinux.banking.client.model.tan.AllowedTanFormat
import net.codinux.banking.client.model.tan.MobilePhoneTanMedium
import net.codinux.banking.client.model.tan.TanGeneratorTanMedium
import net.codinux.banking.client.model.tan.TanMedium
import net.codinux.banking.client.model.tan.TanMediumStatus
import net.codinux.banking.client.model.tan.TanMediumType
import net.codinux.banking.client.model.tan.TanMethod
import net.codinux.banking.client.model.tan.TanMethodType
import net.codinux.banking.client.fints4k.FinTs4kMapper
import net.codinux.banking.persistence.entities.*
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.*
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.settings.UiSettings
import net.codinux.banking.ui.model.settings.ImageSettings
import net.codinux.log.logger
import kotlin.enums.EnumEntries
import kotlin.js.JsName
import kotlin.jvm.JvmName
open class SqliteBankingRepository(
sqlDriver: SqlDriver
) : BankingRepository {
expect fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver
open class SqliteBankingRepository : BankingRepository {
companion object {
val TanMethodTypesToMigrate = mapOf(
"ChipTanManuell" to TanMethodType.ChipTanManual.name,
"ChipTanFlickercode" to TanMethodType.ChipTanFlickerCode.name
)
}
private val schema = BankmeisterDb.Schema
private val sqlDriver = createSqlDriverDriver("Bankmeister.db", schema, 2L)
private val database = BankmeisterDb(sqlDriver)
@ -52,18 +80,30 @@ open class SqliteBankingRepository(
}
override fun getUiSettings(settings: UiSettings) {
settingsQueries.getUiSettings { _, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors ->
settings.transactionsGrouping.value = mapToEnum(transactionsGrouping, TransactionsGrouping.entries)
settings.showBalance.value = showBalance
settings.showBankIcons.value = showBankIcons
settings.showColoredAmounts.value = showColoredAmounts
settings.showTransactionsInAlternatingColors.value = showTransactionsInAlternatingColors
override fun getUiSettings(): UiSettingsEntity? {
return settingsQueries.getUiSettings { _, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors ->
UiSettingsEntity(
showBalance,
mapToEnum(transactionsGrouping, TransactionsGrouping.entries),
showTransactionsInAlternatingColors,
showBankIcons,
showColoredAmounts
)
}.executeAsOneOrNull()
}
override suspend fun saveUiSettings(settings: UiSettings) {
settingsQueries.upsertUiSettings(mapEnum(settings.transactionsGrouping.value), settings.showBalance.value, settings.showBankIcons.value, settings.showColoredAmounts.value, settings.showTransactionsInAlternatingColors.value)
override suspend fun saveUiSettings(settings: UiSettingsEntity) {
settingsQueries.upsertUiSettings(mapEnum(settings.transactionsGrouping), settings.showBalance, settings.showBankIcons, settings.showColoredAmounts, settings.showTransactionsInAlternatingColors)
}
override fun getImageSettings(id: String): ImageSettings? =
settingsQueries.getImageSettings(id) { height, frequency ->
ImageSettings(id, mapToInt(height), mapToInt(frequency))
}.executeAsOneOrNull()
override suspend fun saveImageSettings(settings: ImageSettings) {
settingsQueries.upsertImageSettings(settings.id, mapInt(settings.height), mapInt(settings.frequency))
}
@ -73,9 +113,9 @@ open class SqliteBankingRepository(
val tanMedia = getAllTanMedia().groupBy { it.bankId }.mapValues { it.value.toMutableList() }
val holdings = getAllHoldings().groupBy { it.accountId }
return bankQueries.getAllBanks { id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, selectedTanMethodIdentifier, selectedTanMediumIdentifier, bankingGroup, serverAddress, countryCode, userSetDisplayName, clientData, displayIndex, iconUrl, wrongCredentialsEntered ->
return bankQueries.getAllBanks { id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, selectedTanMethodIdentifier, selectedTanMediumIdentifier, bankingGroup, serverAddress, countryCode, serializedClientData, userSetDisplayName, displayIndex, iconUrl, wrongCredentialsEntered ->
BankAccessEntity(id, domesticBankCode, loginName, password, bankName, bic, customerName, userId, getAccountsOfBank(id, bankAccounts, holdings), selectedTanMethodIdentifier, tanMethods[id] ?: mutableListOf(), selectedTanMediumIdentifier, tanMedia[id] ?: mutableListOf(),
bankingGroup?.let { BankingGroup.valueOf(it) }, serverAddress, countryCode, userSetDisplayName, displayIndex.toInt(), iconUrl, wrongCredentialsEntered)
bankingGroup?.let { BankingGroup.valueOf(it) }, serverAddress, countryCode, userSetDisplayName, displayIndex.toInt(), iconUrl, wrongCredentialsEntered, null, serializedClientData)
}.executeAsList()
}
@ -89,7 +129,7 @@ open class SqliteBankingRepository(
return bankQueries.transactionWithResult {
bankQueries.insertBank(bank.domesticBankCode, bank.loginName, bank.password, bank.bankName, bank.bic,
bank.customerName, bank.userId, bank.selectedTanMethodIdentifier, bank.selectedTanMediumIdentifier,
bank.bankingGroup?.name, bank.serverAddress, bank.countryCode, null, bank.userSetDisplayName, bank.displayIndex.toLong(), bank.iconUrl, bank.wrongCredentialsEntered
bank.bankingGroup?.name, bank.serverAddress, bank.countryCode, bank.serializedClientData, bank.userSetDisplayName, bank.displayIndex.toLong(), bank.iconUrl, bank.wrongCredentialsEntered
)
val bankId = getLastInsertedId() // getLastInsertedId() / last_insert_rowid() has to be called in a transaction with the insert operation, otherwise it will not work
@ -103,6 +143,58 @@ open class SqliteBankingRepository(
}
}
override suspend fun updateBank(bank: BankAccessEntity, loginName: String, password: String, userSetDisplayName: String?) {
bankQueries.transaction {
if (bank.loginName != loginName) {
bankQueries.updateBankLoginName(loginName, bank.id)
}
if (bank.password != password) {
bankQueries.updateBankPassword(password, bank.id)
}
if (bank.userSetDisplayName != userSetDisplayName) {
bankQueries.updateBankUserSetDisplayName(userSetDisplayName, bank.id)
}
}
}
override suspend fun updateBank(bank: BankAccessEntity, serializedClientData: String?) {
bankQueries.transaction {
if (serializedClientData != null) {
bankQueries.updateBankClientData(serializedClientData, bank.id)
}
}
}
override suspend fun updateAccount(account: BankAccountEntity, userSetDisplayName: String?, hideAccount: Boolean, includeInAutomaticAccountsUpdate: Boolean) {
bankQueries.transaction {
if (account.userSetDisplayName != userSetDisplayName) {
bankQueries.updateBankAccountUserSetDisplayName(userSetDisplayName, account.id)
}
if (account.hideAccount != hideAccount) {
bankQueries.updateBankAccountHideAccount(hideAccount, account.id)
}
if (account.includeInAutomaticAccountsUpdate != includeInAutomaticAccountsUpdate) {
bankQueries.updateBankAccountIncludeInAutomaticAccountsUpdate(includeInAutomaticAccountsUpdate, account.id)
}
}
}
override suspend fun updateAccount(account: BankAccountEntity, balance: Amount, lastAccountUpdateTime: Instant, retrievedTransactionsFrom: LocalDate?) {
bankQueries.updateBankAccount(mapAmount(balance), mapInstant(lastAccountUpdateTime), mapDate(retrievedTransactionsFrom), account.id)
}
override suspend fun deleteBank(bank: BankAccessEntity) {
bankQueries.transaction {
accountTransactionQueries.deleteTransactionsByBankId(bankId = bank.id)
bankQueries.deleteBank(bank.id)
}
}
fun getAllBankAccounts(): List<BankAccountEntity> = bankQueries.getAllBankAccounts { id, bankId, identifier, subAccountNumber, iban, productName, accountHolderName, type, currency, accountLimit, isAccountTypeSupportedByApplication, features, balance, serverTransactionsRetentionDays, lastAccountUpdateTime, retrievedTransactionsFrom, userSetDisplayName, displayIndex, hideAccount, includeInAutomaticAccountsUpdate ->
BankAccountEntity(
@ -167,7 +259,7 @@ open class SqliteBankingRepository(
bankId,
displayName,
mapToEnum(type, TanMethodType.entries),
mapToEnum(type, TanMethodType.entries, FinTs4kMapper.TanMethodTypesToMigrate),
identifier,
mapToInt(maxTanInputLength),
mapToEnum(allowedTanFormat, AllowedTanFormat.entries),
@ -257,7 +349,7 @@ open class SqliteBankingRepository(
protected open fun getAllHoldings(): List<HoldingEntity> =
accountTransactionQueries.selectAllHoldings { id, bankId, accountId, name, isin, wkn, quantity, currency, totalBalance, marketValue, performancePercentage, totalCostPrice, averageCostPrice, pricingTime, buyingDate ->
HoldingEntity(id, bankId, accountId, name, isin, wkn, mapToInt(quantity), currency, mapToAmount(totalBalance), mapToAmount(marketValue), performancePercentage?.toFloat(), mapToAmount(totalCostPrice), mapToAmount(averageCostPrice), mapToInstant(pricingTime), mapToDate(buyingDate))
HoldingEntity(id, bankId, accountId, name, isin, wkn, quantity, currency, mapToAmount(totalBalance), mapToAmount(marketValue), performancePercentage?.toFloat(), mapToAmount(totalCostPrice), mapToAmount(averageCostPrice), mapToInstant(pricingTime), mapToDate(buyingDate))
}.executeAsList()
override suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity> =
@ -274,7 +366,7 @@ open class SqliteBankingRepository(
holding.name, holding.isin, holding.wkn,
mapInt(holding.quantity), holding.currency,
holding.quantity, holding.currency,
mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
holding.performancePercentage?.toDouble(),
@ -291,7 +383,7 @@ open class SqliteBankingRepository(
holdings.onEach { holding ->
accountTransactionQueries.updateHolding(
holding.name, holding.isin, holding.wkn,
mapInt(holding.quantity), holding.currency,
holding.quantity, holding.currency,
mapAmount(holding.totalBalance), mapAmount(holding.marketValue),
holding.performancePercentage?.toDouble(),
@ -315,8 +407,8 @@ open class SqliteBankingRepository(
override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
accountTransactionQueries.getAllTransactionsAsViewModel { id, bankId, accountId, amount, currency, reference, valueDate, otherPartyName, postingText, userSetDisplayName, userSetOtherPartyName ->
AccountTransactionViewModel(id, bankId, accountId, mapToAmount(amount), currency, reference, mapToDate(valueDate), otherPartyName, postingText, userSetDisplayName, userSetOtherPartyName)
accountTransactionQueries.getAllTransactionsAsViewModel { id, bankId, accountId, amount, currency, reference, valueDate, otherPartyName, postingText, userSetReference, userSetOtherPartyName ->
AccountTransactionViewModel(id, bankId, accountId, mapToAmount(amount), currency, reference, mapToDate(valueDate), otherPartyName, postingText, userSetReference, userSetOtherPartyName)
}.executeAsList()
override fun getAllAccountTransactions(): List<AccountTransactionEntity> {
@ -379,6 +471,22 @@ open class SqliteBankingRepository(
return AccountTransactionEntity(getLastInsertedId(), bankId, accountId, transaction)
}
override suspend fun updateTransaction(transaction: AccountTransactionEntity, userSetOtherPartyName: String?, userSetReference: String?, notes: String?) {
accountTransactionQueries.transaction {
if (transaction.userSetOtherPartyName != userSetOtherPartyName) {
accountTransactionQueries.updateAccountTransactionUserSetOtherPartyName(userSetOtherPartyName, transaction.id)
}
if (transaction.userSetReference != userSetReference) {
accountTransactionQueries.updateAccountTransactionUserSetOReference(userSetReference, transaction.id)
}
if (transaction.notes != notes) {
accountTransactionQueries.updateAccountTransactionNotes(notes, transaction.id)
}
}
}
private fun getLastInsertedId(): Long =
bankQueries.getLastInsertedId().executeAsOne()
@ -497,7 +605,16 @@ open class SqliteBankingRepository(
private fun <E : Enum<E>> mapEnum(enum: Enum<E>): String = enum.name
private fun <E : Enum<E>> mapToEnum(enumName: String, values: EnumEntries<E>): E =
try {
values.first { it.name == enumName }
} catch (e: Throwable) {
log.error(e) { "Could not map enumName '$enumName' to ${values.first()::class}"}
throw e
}
private fun <E : Enum<E>> mapToEnum(enumName: String, values: EnumEntries<E>, enumNamesToMigrate: Map<String, String>): E =
mapToEnum(enumNamesToMigrate[enumName] ?: enumName, values)
private fun <E : Enum<E>> mapToEnumNullable(enumName: String, values: EnumEntries<E>): E? {
val mapped = values.firstOrNull { it.name == enumName }

View File

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

View File

@ -1,8 +1,7 @@
package net.codinux.banking.dataaccess.entities
package net.codinux.banking.persistence.entities
import net.codinux.banking.client.model.BankAccess
import net.codinux.banking.client.model.BankingGroup
import net.codinux.banking.client.model.tan.TanMedium
class BankAccessEntity(
val id: Long,
@ -33,7 +32,10 @@ class BankAccessEntity(
displayIndex: Int = 0,
iconUrl: String? = null,
wrongCredentialsEntered: Boolean = false
wrongCredentialsEntered: Boolean = false,
clientData: Any? = null,
serializedClientData: String? = null
) : BankAccess(domesticBankCode, loginName, password, bankName, bic, customerName, userId, accounts, selectedTanMethodIdentifier, tanMethods, selectedTanMediumIdentifier, tanMedia, bankingGroup, serverAddress, countryCode) {
init {
@ -42,6 +44,9 @@ class BankAccessEntity(
this.iconUrl = iconUrl
this.wrongCredentialsEntered = wrongCredentialsEntered
this.clientData = clientData
this.serializedClientData = serializedClientData
}
@ -55,4 +60,14 @@ class BankAccessEntity(
bank.iconUrl, bank.wrongCredentialsEntered,
)
override val accountsSorted: List<BankAccountEntity>
get() = accounts.sortedBy { it.displayIndex }
override val tanMethodsSorted: List<TanMethodEntity>
get() = tanMethods.sortedBy { it.identifier }
override val tanMediaSorted: List<TanMediumEntity>
get() = tanMedia.sortedBy { it.status }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
package net.codinux.banking.persistence.entities
import net.codinux.banking.ui.model.settings.TransactionsGrouping
class UiSettingsEntity(
val showBalance: Boolean,
val transactionsGrouping: TransactionsGrouping,
val showTransactionsInAlternatingColors: Boolean,
val showBankIcons: Boolean,
val showColoredAmounts: Boolean
)

View File

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

View File

@ -0,0 +1,11 @@
package net.codinux.banking.ui.model.settings
class ImageSettings(
val id: String,
var height: Int,
var frequency: Int? = null // only needed for flicker code
) {
override fun toString() = "$id $height${if (frequency != null) " (frequency = $frequency)" else ""}"
}

View File

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

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS ImageSettings (
id TEXT PRIMARY KEY,
height INTEGER NOT NULL,
width INTEGER, -- not used right now, add it just in case
frequency INTEGER
);
ALTER TABLE Holding DROP COLUMN quantity;
ALTER TABLE Holding ADD COLUMN quantity REAL;

View File

@ -145,6 +145,31 @@ SELECT AccountTransaction.*
FROM AccountTransaction WHERE id = ?;
updateAccountTransactionUserSetOtherPartyName:
UPDATE AccountTransaction
SET userSetOtherPartyName = ?
WHERE id = ?;
updateAccountTransactionUserSetOReference:
UPDATE AccountTransaction
SET userSetReference = ?
WHERE id = ?;
updateAccountTransactionNotes:
UPDATE AccountTransaction
SET notes = ?
WHERE id = ?;
deleteTransactionsByBankId {
DELETE FROM BankAccount
WHERE bankId = :bankId;
DELETE FROM Holding
WHERE bankId = :bankId;
}
CREATE TABLE IF NOT EXISTS Holding (
@ -157,7 +182,7 @@ CREATE TABLE IF NOT EXISTS Holding (
isin TEXT,
wkn TEXT,
quantity INTEGER ,
quantity REAL,
currency TEXT,
totalBalance TEXT,

View File

@ -79,6 +79,44 @@ SELECT BankAccess.*
FROM BankAccess;
updateBankLoginName:
UPDATE BankAccess
SET loginName = ?
WHERE id = ?;
updateBankPassword:
UPDATE BankAccess
SET password = ?
WHERE id = ?;
updateBankUserSetDisplayName:
UPDATE BankAccess
SET userSetDisplayName = ?
WHERE id = ?;
updateBankClientData:
UPDATE BankAccess
SET clientData = ?
WHERE id = ?;
deleteBank {
DELETE FROM TanMethod
WHERE bankId = :bankId;
DELETE FROM TanMedium
WHERE bankId = :bankId;
DELETE FROM BankAccount
WHERE bankId = :bankId;
DELETE FROM BankAccess
WHERE id = :bankId;
}
CREATE TABLE IF NOT EXISTS BankAccount (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -155,6 +193,28 @@ SELECT BankAccount.*
FROM BankAccount;
updateBankAccount:
UPDATE BankAccount
SET balance = :balance, lastAccountUpdateTime = :lastAccountUpdateTime, retrievedTransactionsFrom = :retrievedTransactionsFrom
WHERE id = :accountId;
updateBankAccountUserSetDisplayName:
UPDATE BankAccount
SET userSetDisplayName = ?
WHERE id = ?;
updateBankAccountHideAccount:
UPDATE BankAccount
SET hideAccount = ?
WHERE id = ?;
updateBankAccountIncludeInAutomaticAccountsUpdate:
UPDATE BankAccount
SET includeInAutomaticAccountsUpdate = ?
WHERE id = ?;
CREATE TABLE IF NOT EXISTS TanMethod (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -70,3 +70,22 @@ SELECT * FROM UiSettings WHERE id = 1;
upsertUiSettings:
INSERT OR REPLACE INTO UiSettings(id, transactionsGrouping, showBalance, showBankIcons, showColoredAmounts, showTransactionsInAlternatingColors)
VALUES (1, ?, ?, ?, ?, ?);
CREATE TABLE IF NOT EXISTS ImageSettings (
id TEXT PRIMARY KEY,
height INTEGER NOT NULL,
width INTEGER, -- not used right now, add it just in case
frequency INTEGER
);
getImageSettings:
SELECT height, frequency FROM ImageSettings WHERE id = ?;
upsertImageSettings:
INSERT OR REPLACE INTO ImageSettings(id, height, frequency)
VALUES (?, ?, ?);

View File

@ -0,0 +1,10 @@
package net.codinux.banking.persistence
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver =
NativeSqliteDriver(schema.synchronous(), dbName)

View File

@ -0,0 +1,9 @@
package net.codinux.banking.persistence
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver {
throw NotImplementedError("TODO")
}

View File

@ -0,0 +1,79 @@
package net.codinux.banking.persistence
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import java.io.File
import java.nio.file.Files
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
val dataDirectory: File = determineDataDirectory()
actual fun createSqlDriverDriver(dbName: String, schema: SqlSchema<QueryResult.AsyncValue<Unit>>, version: Long): SqlDriver {
val dbDir = File(dataDirectory, "db").also { it.mkdirs() }
val databaseFile = File(dbDir, dbName)
return JdbcSqliteDriver("jdbc:sqlite:${databaseFile.path}").also { driver ->
schema.synchronous().also { schema ->
if (databaseFile.exists() == false) {
schema.create(driver)
}
schema.migrate(driver, schema.version, version)
}
}
}
private fun setSchemaVersion(driver: SqlDriver, schemaVersion: Int) {
driver.execute(null, "PRAGMA schema_version=$schemaVersion;", 0)
}
private fun determineDataDirectory(): File {
val currentDir = Path(System.getProperty("user.dir"))
// if the current directory is writable, use that one (the default for development)
val dataDir = if (Files.isWritable(currentDir)) { // couldn't believe it, but java.io.File returned folder is writable for "C:\\Program Files\\"
File(currentDir.absolutePathString(), "data")
} else { // otherwise use .bankmeister dir in user's home dir (the default for releases)
File(determineOsDependentUserDataDir(), ".bankmeister")
}
return dataDir.also { it.mkdirs() }
}
private fun determineOsDependentUserDataDir(): File {
val userHomeString = System.getProperty("user.home")
val userHome = File(userHomeString)
val windowsLocalAppDataDir = System.getenv("LOCALAPPDATA")?.takeUnless { it.isBlank() }
return if (windowsLocalAppDataDir != null) {
File(windowsLocalAppDataDir)
} else if (userHomeString.startsWith("/")) {
val osName = System.getProperty("os.name")
if (osName.contains("mac", true) || osName.contains("darwin", true)) { // macOS
File(userHome, "Library/Application Support")
} else if (osName.contains("nux")) { // Linux
val localShareDirectory = File(userHome, ".local/share")
val configDir = File(userHome, ".config")
if (localShareDirectory.exists()) {
localShareDirectory
} else if (configDir.exists()) {
configDir
} else {
userHome
}
} else { // unknown
userHome
}
} else if (userHomeString.length > 3 && userHomeString[1] == ':' && userHomeString[2] == '\\') { // Windows, but LOCALAPPDATA is not set
userHome // File(userHome, "AppData\Local")
} else {
userHome
}
}

View File

@ -1,23 +1,18 @@
package net.codinux.banking.dataaccess
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.*
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.persistence.SqliteBankingRepository
import net.codinux.banking.persistence.entities.AccountTransactionEntity
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class SqliteBankingRepositoryTest {
private val sqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).apply {
BankmeisterDb.Schema.synchronous().create(this)
}
private val underTest = object : SqliteBankingRepository(sqlDriver) {
override public suspend fun persistTransaction(bankId: Long, accountId: Long, transaction: AccountTransaction): AccountTransactionEntity =
private val underTest = object : SqliteBankingRepository() {
public override suspend fun persistTransaction(bankId: Long, accountId: Long, transaction: AccountTransaction): AccountTransactionEntity =
super.persistTransaction(bankId, accountId, transaction)
}

View File

@ -1,7 +1,7 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.tasks.AbstractJarsFlattenTask
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins {
@ -11,18 +11,23 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.sqldelight)
}
kotlin {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
// suppresses compiler warning: [EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING] 'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta.
freeCompilerArgs.add("-Xexpect-actual-classes")
}
js {
moduleName = "composeApp"
moduleName = "Bankmeister"
browser {
val projectDirPath = project.projectDir.path
commonWebpackConfig {
outputFileName = "composeApp.js"
outputFileName = "Bankmeister.js"
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
static = (static ?: mutableListOf()).apply {
// Serve sources to debug inside browser
@ -50,26 +55,36 @@ kotlin {
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
baseName = "BankmeisterFramework"
isStatic = false
}
// don't know why but this has to be added here, adding it in BankingPersistence.build.gradle.kt does not work
iosTarget.binaries.forEach { binary ->
if (binary is org.jetbrains.kotlin.gradle.plugin.mpp.Framework) {
binary.linkerOpts.add("-lsqlite3") // without this we get a lot of "Undefined symbol _co_touchlab_sqliter..." errors in Xcode
}
}
}
applyDefaultHierarchyTemplate()
sourceSets {
val desktopMain by getting
commonMain.dependencies {
implementation(project(":BankingPersistence"))
implementation(libs.banking.client.model)
implementation(libs.fints4k.banking.client)
implementation(libs.bank.finder)
implementation(libs.epcqrcode)
implementation(libs.kcsv)
implementation(libs.klf)
implementation(libs.kotlinx.serializable)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines.extensions)
implementation(libs.sqldelight.paging.extensions)
// UI
implementation(compose.runtime)
implementation(compose.foundation)
@ -92,12 +107,20 @@ kotlin {
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment) // to fix bug IllegalArgumentException: Can only use lower 16 bits for requestCode
implementation(libs.androidx.biometric)
implementation(libs.sqldelight.android.driver)
implementation(libs.favre.bcrypt)
// for reading EPC QR Codes from camera
implementation(libs.zxing.core)
implementation(libs.camerax.camera2)
implementation(libs.camerax.view)
implementation(libs.camerax.lifecycle)
}
nativeMain.dependencies {
implementation(libs.sqldelight.native.driver)
iosMain.dependencies {
}
jvmTest.dependencies {
@ -108,21 +131,10 @@ kotlin {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.sqldelight.sqlite.driver)
implementation(libs.favre.bcrypt)
implementation(libs.logback)
}
}
}
sqldelight {
databases {
create("BankmeisterDb") {
packageName.set("net.codinux.banking.dataaccess")
generateAsync = true
schemaOutputDirectory = file("src/commonMain/sqldelight/databases")
implementation(libs.janino)
}
}
}
@ -140,8 +152,8 @@ android {
applicationId = "net.codinux.banking.android" // the appId of the old Bankmeister app to be able to use the old PlayStore entry
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 10
versionName = "1.0.0-Alpha-12"
versionCode = 21
versionName = "1.0.0-Alpha-15"
}
packaging {
resources {
@ -189,11 +201,34 @@ compose.desktop {
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "net.codinux.banking.ui"
packageVersion = "1.0.0"
modules("java.sql", "java.naming") // java.naming is required by logback
packageName = "Bankmeister"
packageVersion = "0.9.0" // minor version < 1 (DMG) and dashes as in '1.0.0-Alpha-14' (DMG, MSI, RPM) are not allowed
description = "Datenschutzfreundliche Multi-Banking App für die meisten deutschen Banken"
copyright = "© 2024 codinux GmbH & Co.KG. All rights reserved."
vendor = "codinux GmbH & Co.KG"
macOS {
bundleID = "net.codinux.banking.ui"
appCategory = "public.app-category.finance"
dmgPackageVersion = "1.0.0"
iconFile = project.file("../docs/res/AppIcons/distributions/AppIcon.icns")
}
windows {
// a unique ID, which enables users to update an app via installer, when an updated version is newer, than an installed version.
// The value must remain constant for a single application. See [the link](https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html) for details on generating a UUID.
upgradeUuid = "F62896E2-382E-4311-9683-1AB3AA4EB9E7"
menu = true
iconFile = project.file("../docs/res/AppIcons/distributions/AppIcon.ico")
}
linux {
iconFile = project.file("../docs/res/AppIcons/distributions/AppIcon.png")
}
}
buildTypes.release.proguard {
@ -202,3 +237,58 @@ compose.desktop {
}
}
}
gradle.taskGraph.whenReady {
tasks.named<AbstractJarsFlattenTask>("flattenJars") {
removeThirdPartySignaturesFromJar()
}
tasks.named<AbstractJarsFlattenTask>("flattenReleaseJars") {
removeThirdPartySignaturesFromJar()
}
}
// Signatures of third party libraries get copied to output jar's META-INF folder so that java -jar refuses to run created uber jar:
// Error: A JNI error has occurred, please check your installation and try again
// Exception in thread "main" java.lang.SecurityException: Invalid signature file digest for Manifest main attributes
// at java.base/sun.security.util.SignatureFileVerifier.processImpl(SignatureFileVerifier.java:340)
// at java.base/sun.security.util.SignatureFileVerifier.process(SignatureFileVerifier.java:282)
// at java.base/java.util.jar.JarVerifier.processEntry(JarVerifier.java:276)
//
// -> remove signatures of third party libraries from jar's META-INF folder
fun AbstractJarsFlattenTask.removeThirdPartySignaturesFromJar() {
val outputJar = (this.flattenedJar as? FileSystemLocationProperty<*>)?.asFile?.get()
doLast {
if (outputJar != null && outputJar.exists()) {
val extractedFilesFolder = File(outputJar.parentFile, "extracted").also { it.mkdirs() }
extractedFilesFolder.deleteRecursively()
project.copy { // unzip jar file
from(project.zipTree(outputJar))
into(extractedFilesFolder)
}
// Remove unwanted META-INF files (*.SF, *.DSA, *.RSA)
project.fileTree(extractedFilesFolder.resolve("META-INF")).matching {
include("*.SF", "*.DSA", "*.RSA")
}.forEach {
it.delete() // Delete the matching signature files
}
outputJar.delete() // Remove the original JAR
// Zip the modified content back into a new JAR using Ant
ant.withGroovyBuilder {
"zip"(
"destfile" to outputJar,
"basedir" to extractedFilesFolder
)
}
// Clean up the temporary directory
extractedFilesFolder.deleteRecursively()
}
}
}

View File

@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"

View File

@ -1,30 +1,55 @@
package net.codinux.banking.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.service.ImageService
import androidx.fragment.app.FragmentActivity
import net.codinux.banking.persistence.AndroidContext
import net.codinux.banking.ui.service.AuthenticationService
import net.codinux.banking.ui.service.BiometricAuthenticationService
class MainActivity : FragmentActivity() {
private val request = ActivityResultContracts.RequestMultiplePermissions()
private val activityResultLauncher = registerForActivityResult(request) { }
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ImageService.context = this.applicationContext
AndroidContext.applicationContext = this.applicationContext
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))
AuthenticationService.biometricAuthenticationService = BiometricAuthenticationService(this)
setContent {
App()
}
}
fun requestPermissions(requiredPermissions: List<String>): Boolean {
val requiredPermissionsArray = requiredPermissions.toTypedArray()
activityResultLauncher.launch(requiredPermissionsArray)
var result = request.getSynchronousResult(baseContext, requiredPermissionsArray)
while (result == null) {
result = request.getSynchronousResult(baseContext, requiredPermissionsArray)
}
return if (result.value != null) {
val allPermissionsGranted = result.value.entries.filter { it.key in requiredPermissions }.all { it.value == true }
allPermissionsGranted
} else {
false
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {

View File

@ -1,8 +1,10 @@
package net.codinux.banking.ui
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineDispatcher
@ -11,6 +13,18 @@ import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IOorDefault: CoroutineDispatcher
get() = Dispatchers.IO
actual fun KeyEvent.isBackButtonPressedEvent(): Boolean =
this.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_BACK
@Composable
actual fun systemPaddings(): PaddingValues = PaddingValues(0.dp)
actual fun addKeyboardVisibilityListener(onKeyboardVisibilityChanged: (Boolean) -> Unit) {
// TODO: may implement, but currently only relevant for iOS
}
@Composable
actual fun rememberScreenSizeInfo(): ScreenSizeInfo {
val config = LocalConfiguration.current

View File

@ -10,8 +10,8 @@ import net.codinux.banking.ui.forms.RoundedCornersCard
@Preview
@Composable
fun HoldingListItemPreview() {
val holding1 = Holding("MUL Amundi MSCI World V", null, null, 1693, "EUR", Amount("18578.04"), Amount("16.888"), -0.35f, Amount("17944.48"), Amount("16.828"))
val holding2 = Holding("NVIDIA Corp.", null, null, 214, "EUR", Amount("21455.36"), Amount("100.18"), 8.8f, Amount("19872.04"), Amount("92.04"))
val holding1 = Holding("MUL Amundi MSCI World V", null, null, 1693.0, "EUR", Amount("18578.04"), Amount("16.888"), -0.35f, Amount("17944.48"), Amount("16.828"))
val holding2 = Holding("NVIDIA Corp.", null, null, 214.0, "EUR", Amount("21455.36"), Amount("100.18"), 8.8f, Amount("19872.04"), Amount("92.04"))
RoundedCornersCard {
Column {

View File

@ -29,6 +29,19 @@ fun EnterTanDialogPreview_TanImage() {
val bank = BankViewInfo("10010010", "Ihr krasser Login Name", "Phantasie Bank", BankingGroup.Comdirect)
val tanChallenge = TanChallenge(TanChallengeType.Image, ActionRequiringTan.TransferMoney, "Geben Sie die TAN ein", tanMethod.identifier, listOf(tanMethod), null, emptyList(), tanImage, null, bank)
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
}
@Preview
@Composable
fun EnterTanDialogPreview_TanImage_DecodingError() {
val tanMethod = TanMethod("photoTAN-Verfahren", TanMethodType.photoTan, "902", 6, AllowedTanFormat.Numeric)
val tanImage = TanImage(null, null, "Ja Hoppla, da ist dann wohl etwas schief gelaufen. Hinterfragen Sie Ihre Existenz woran das liegen könnte!")
val bank = BankViewInfo("10010010", "Ihr krasser Login Name", "Phantasie Bank", BankingGroup.Comdirect)
val tanChallenge = TanChallenge(TanChallengeType.Image, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethod.identifier, listOf(tanMethod), null, emptyList(), tanImage, null, bank)
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
@ -41,7 +54,7 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall
val tanImage = TanImage("image/png", tanImageBytes)
val tanMethods = listOf(
TanMethod("chipTAN optisch", TanMethodType.ChipTanFlickercode, "911", 6, AllowedTanFormat.Numeric),
TanMethod("chipTAN optisch", TanMethodType.ChipTanFlickerCode, "911", 6, AllowedTanFormat.Numeric),
TanMethod("chipTAN-QR", TanMethodType.ChipTanQrCode, "913", 6, AllowedTanFormat.Numeric)
)
@ -60,10 +73,20 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall
@Preview
@Composable
fun EnterTanDialogPreview_Flickercode() {
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickercode, "902"))
fun EnterTanDialogPreview_FlickerCode() {
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickerCode, "902"))
val bank = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank)
val tanChallenge = TanChallenge(TanChallengeType.Flickercode, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("", ""))
val tanChallenge = TanChallenge(TanChallengeType.FlickerCode, ActionRequiringTan.GetTanMedia, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("100880077104", "0604800771040F"))
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
}
@Preview
@Composable
fun EnterTanDialogPreview_FlickerCode_DecodingError() {
val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickerCode, "902"))
val bank = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank)
val tanChallenge = TanChallenge(TanChallengeType.FlickerCode, ActionRequiringTan.ChangeTanMedium, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("100880077104", null, decodingError = "Ja Hoppla, da ist dann wohl etwas schief gelaufen."))
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
}

View File

@ -0,0 +1,24 @@
package net.codinux.banking.ui.forms
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
@Preview
@Composable
fun SegmentedControlPreview() {
SegmentedControl(
options = listOf("Option 1", "Option 2", "Option 3"),
selectedOption = "Option 1",
onOptionSelected = { }
)
}
@Preview
@Composable
fun SegmentedControlPreview_OnlyTwoOptions() {
SegmentedControl(
options = listOf("Option 1", "Option 2"),
selectedOption = "Option 2",
onOptionSelected = { }
)
}

View File

@ -0,0 +1,15 @@
package net.codinux.banking.ui.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.model.settings.AppSettings
@Preview
@Composable
fun ProtectAppSettingsDialogPreview() {
val appSettings = AppSettings(AppAuthenticationMethod.Password)
ProtectAppSettingsDialog(appSettings) { }
}

View File

@ -0,0 +1,29 @@
package net.codinux.banking.ui.service
import at.favre.lib.crypto.bcrypt.BCrypt
import net.codinux.banking.ui.model.AuthenticationResult
actual object AuthenticationService {
internal var biometricAuthenticationService: BiometricAuthenticationService? = null
actual fun hashPassword(password: String): String =
BCrypt.withDefaults().hashToString(12, password.toCharArray())
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
actual val supportsBiometricAuthentication: Boolean
get() = biometricAuthenticationService?.supportsBiometricAuthentication ?: false
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
if (biometricAuthenticationService != null) {
biometricAuthenticationService!!.authenticate(null, authenticationResult)
} else {
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
}
}
}

View File

@ -0,0 +1,66 @@
package net.codinux.banking.ui.service
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import net.codinux.banking.ui.R
import net.codinux.banking.ui.model.AuthenticationResult
import javax.crypto.Cipher
class BiometricAuthenticationService(
private val activity: FragmentActivity
) {
private val biometricManager: BiometricManager = BiometricManager.from(activity)
private val allowedAuthenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) BIOMETRIC_STRONG
else BIOMETRIC_STRONG or BIOMETRIC_WEAK
val supportsBiometricAuthentication: Boolean by lazy {
biometricManager.canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(cipher: Cipher?, authenticationResult: (AuthenticationResult) -> Unit) {
val executor = ContextCompat.getMainExecutor(this.activity)
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
super.onAuthenticationError(errorCode, errorString)
authenticationResult(AuthenticationResult(false, errorString.toString()))
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
authenticationResult(AuthenticationResult(true))
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
authenticationResult(AuthenticationResult(false))
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(this.activity.getString(R.string.activity_login_authenticate_with_biometrics_prompt))
//.setSubtitle() // TODO: add subtitle?
.setNegativeButtonText(this.activity.getString(android.R.string.cancel)) // is not allowed when device credentials are allowed
.setAllowedAuthenticators(allowedAuthenticators)
.build()
if (cipher == null) {
biometricPrompt.authenticate(promptInfo)
} else {
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
}

View File

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

View File

@ -0,0 +1,13 @@
package net.codinux.banking.ui.service
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
object PermissionsService {
fun allPermissionsGranted(baseContext: Context, permissions: List<String>) = permissions.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
}

View File

@ -0,0 +1,186 @@
package net.codinux.banking.ui.service
import android.Manifest
import android.content.pm.PackageManager
import android.view.ScaleGestureDetector
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.zxing.*
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import net.codinux.banking.persistence.AndroidContext
import net.codinux.banking.ui.MainActivity
import net.codinux.log.logger
import java.nio.ByteBuffer
import java.util.concurrent.Executors
actual object QrCodeService {
private val RequiredPermissions = listOf(Manifest.permission.CAMERA)
private val cameraExecutor = Executors.newCachedThreadPool()
private val log by logger()
actual val supportsReadingQrCodesFromCamera = hasCamera()
private fun hasCamera(): Boolean = AndroidContext.applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
@Composable
actual fun readQrCodeFromCamera(resultCallback: (QrCodeReadResult) -> Unit) {
val mainActivity = LocalLifecycleOwner.current as MainActivity // we only have MainActivity, so we can be sure that LocalLifecycleOwner.current is MainActivity
if (PermissionsService.allPermissionsGranted(AndroidContext.applicationContext, RequiredPermissions) == false &&
mainActivity.requestPermissions(RequiredPermissions) == false) {
return // we don't have the permission to start the camera
}
val previewView = remember {
PreviewView(mainActivity)
}
setupCameraView(previewView, mainActivity, resultCallback)
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
}
private fun setupCameraView(previewView: PreviewView, mainActivity: MainActivity, resultCallback: (QrCodeReadResult) -> Unit) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(mainActivity)
cameraProviderFuture.addListener({
// Used to bind the lifecycle of cameras to the lifecycle owner
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// Preview
val preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// Select back camera as a default
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
val imageAnalyzer = ImageAnalysis.Builder()
.build()
.also {
it.setAnalyzer(cameraExecutor, QrCodeImageAnalyzer(resultCallback))
}
try {
// Unbind use cases before rebinding
cameraProvider.unbindAll()
// Bind use cases to camera
val camera = cameraProvider.bindToLifecycle(mainActivity, cameraSelector, preview, imageAnalyzer)
configureCameraControl(camera, previewView, mainActivity)
} catch (e: Exception) {
log.error(e) { "Use case binding failed" }
}
}, ContextCompat.getMainExecutor(mainActivity))
}
private fun configureCameraControl(
camera: Camera,
previewView: PreviewView,
mainActivity: MainActivity
) {
// Listen to pinch gestures
val listener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
// Get the camera's current zoom ratio
val currentZoomRatio = camera.cameraInfo.zoomState.value?.zoomRatio ?: 0F
// Get the pinch gesture's scaling factor
val delta = detector.scaleFactor
// Update the camera's zoom ratio. This is an asynchronous operation that returns
// a ListenableFuture, allowing you to listen to when the operation completes.
camera.cameraControl.setZoomRatio(currentZoomRatio * delta)
return true // Return true, as the event was handled
}
}
val scaleGestureDetector = ScaleGestureDetector(mainActivity, listener)
previewView.setOnTouchListener { _, event ->
scaleGestureDetector.onTouchEvent(event)
true
}
}
}
class QrCodeImageAnalyzer(private val resultCallback: (QrCodeReadResult) -> Unit) : ImageAnalysis.Analyzer {
private val reader = QRCodeReader()
private val readerHints = readerHintsForCharset(Charsets.UTF_8.name())
private val log by logger()
override fun analyze(image: ImageProxy) {
try {
val bitmap = getBinaryBitmap(image)
val result = reader.decode(bitmap, readerHints)
if (result != null && result.text != null) {
this.resultCallback(QrCodeReadResult(result.text))
}
} catch (e: Throwable) {
if (e !is NotFoundException) {
log.error(e) { "Could not decode image to QR code" }
}
}
image.close() // to continue image analysis / avoid blocking production of further images
}
private fun ByteBuffer.toIntArray(): IntArray {
val bytes = this.toByteArray()
val pixels = IntArray(bytes.size)
bytes.indices.forEach { index ->
pixels[index] = bytes[index].toInt() and 0xFF
}
return pixels
}
private fun ByteBuffer.toByteArray(): ByteArray {
rewind() // Rewind the buffer to zero
val data = ByteArray(remaining())
get(data) // Copy the buffer into a byte array
return data // Return the byte array
}
private fun getBinaryBitmap(image: ImageProxy): BinaryBitmap {
val buffer = image.planes[0].buffer
val bitmapBuffer = buffer.toIntArray()
val luminanceSource = RGBLuminanceSource(image.width, image.height, bitmapBuffer)
return BinaryBitmap(HybridBinarizer(luminanceSource))
}
private fun readerHintsForCharset(charset: String): Map<DecodeHintType, *> = buildMap {
// put(DecodeHintType.TRY_HARDER, true) // optimize for accuracy, not speed
put(DecodeHintType.CHARACTER_SET, charset)
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -1,47 +0,0 @@
package net.codinux.banking.dataaccess
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.BankAccess
import net.codinux.banking.client.model.securitiesaccount.Holding
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.HoldingEntity
import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.settings.AppSettings
import net.codinux.banking.ui.settings.UiSettings
interface BankingRepository {
fun getAppSettings(): AppSettings?
suspend fun saveAppSettings(settings: AppSettings)
fun getUiSettings(settings: UiSettings)
suspend fun saveUiSettings(settings: UiSettings)
fun getAllBanks(): List<BankAccessEntity>
suspend fun persistBank(bank: BankAccess): BankAccessEntity
suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity>
suspend fun persistHoldings(bankAccount: BankAccountEntity, holdings: List<Holding>): List<HoldingEntity>
suspend fun updateHoldings(holdings: List<HoldingEntity>)
suspend fun deleteHoldings(holdings: List<HoldingEntity>)
fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel>
fun getAllAccountTransactions(): List<AccountTransactionEntity>
fun getAllTransactionsForBank(bank: BankAccessEntity): List<AccountTransactionEntity>
fun getTransactionById(transactionId: Long): AccountTransactionEntity?
}

View File

@ -7,9 +7,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import net.codinux.banking.persistence.BankingRepository
import net.codinux.banking.persistence.SqliteBankingRepository
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
import net.codinux.banking.ui.screens.LoginScreen
import net.codinux.banking.ui.screens.MainScreen
import net.codinux.log.Log
import net.codinux.log.LoggerFactory
import org.jetbrains.compose.ui.tooling.preview.Preview
@ -19,20 +24,40 @@ private val typography = Typography(
@Composable
@Preview
fun App() {
fun App(repository: BankingRepository? = null) {
LoggerFactory.defaultLoggerName = "net.codinux.banking.ui.Bankmeister"
val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, onPrimary = Color.White,
secondary = Colors.Accent, secondaryVariant = Colors.Accent, onSecondary = Color.White)
var isInitialized by remember { mutableStateOf(false) }
try {
if (isInitialized == false) {
DI.setRepository(repository ?: SqliteBankingRepository()) // setting repository sets AppSettings, which is required below to determine if user needs to log in
}
} catch (e: Throwable) {
Log.error(e) { "Could not set repository" }
}
val appSettings = DI.uiState.appSettings.collectAsState().value
var isLoggedIn by remember(appSettings.authenticationMethod) { mutableStateOf(appSettings.authenticationMethod == AppAuthenticationMethod.None) }
val coroutineScope = rememberCoroutineScope()
MaterialTheme(colors = colors, typography = typography) {
if (isLoggedIn == false) {
LoginScreen(appSettings) {
isLoggedIn = true
}
} else {
MainScreen()
}
}
LaunchedEffect(isInitialized) {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,113 @@
package net.codinux.banking.ui.appskeleton
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style.FabMenuSpacing
import net.codinux.banking.ui.config.Style.FabSize
import net.codinux.banking.ui.config.Style.SmallFabSize
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
import net.codinux.banking.ui.service.QrCodeService
private val uiState = DI.uiState
@Composable
fun FloatingActionMenu(
showFloatingActionMenu: Boolean,
menuItemClicked: () -> Unit
) {
val fabVisibilityAnimation = animateFloatAsState(targetValue = if (showFloatingActionMenu) 1f else 0f)
val bottomPadding = FabSize + FabSize / 2
val accountsThatSupportMoneyTransfer = uiState.accountsThatSupportMoneyTransfer.collectAsState().value
val coroutineScope = rememberCoroutineScope()
fun handleClick(action: () -> Unit) {
menuItemClicked()
coroutineScope.launch {
delay(50)
action()
}
}
if (fabVisibilityAnimation.value > 0) {
Box(Modifier.fillMaxSize().padding(bottom = bottomPadding, end = 12.dp), contentAlignment = Alignment.BottomEnd) {
Column(Modifier, horizontalAlignment = Alignment.End) {
FloatingActionMenuItem("Überweisungs-QR-Code erstellen", "EPC QR Code erstellen") {
handleClick {
uiState.showCreateEpcQrCodeScreen.value = true
}
}
if (QrCodeService.supportsReadingQrCodesFromCamera) {
FloatingActionMenuItem("Überweisungs-QR-Code lesen", "Neue Überweisung mit Daten aus EPC QR Code (GiroCode, scan2Code, Zahlen mit Code, ...)", enabled = accountsThatSupportMoneyTransfer.isNotEmpty()) {
handleClick {
uiState.showTransferMoneyFromEpcQrCodeScreen.value = true
}
}
}
FloatingActionMenuItem("Überweisung", "Neue Überweisung", enabled = accountsThatSupportMoneyTransfer.isNotEmpty()) {
handleClick {
uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData()
}
}
FloatingActionMenuItem("Konto", "Neues Konto hinzufügen") {
handleClick {
uiState.showAddAccountDialog.value = true
}
}
}
}
}
}
@Composable
fun FloatingActionMenuItem(
label: String,
contentDescription: String,
enabled: Boolean = true,
onClick: () -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = FabMenuSpacing).clickable(enabled) { onClick() }) {
Text(label, fontSize = 16.sp, color = contentColorFor(MaterialTheme.colors.secondary).copy(if (enabled) 1f else ContentAlpha.disabled), modifier = Modifier.padding(end = 8.dp).background(MaterialTheme.colors.secondary).padding(horizontal = 20.dp, vertical = 4.dp)) // the same background color as the FAB
FloatingActionButton(
shape = CircleShape,
modifier = Modifier.padding(end = (FabSize - SmallFabSize) / 2).size(SmallFabSize),
onClick = {
if (enabled) {
onClick()
}
}
) {
Icon(Icons.Filled.Add, contentDescription = contentDescription, tint = LocalContentColor.current.copy(if (enabled) LocalContentAlpha.current else ContentAlpha.disabled))
}
}
}

View File

@ -5,8 +5,10 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.SaveAs
import androidx.compose.material.icons.outlined.Key
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
@ -23,7 +25,7 @@ import net.codinux.banking.ui.composables.settings.UiSettings
import net.codinux.banking.ui.composables.text.ItemDivider
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.ShowTransferMoneyDialogData
import net.codinux.banking.ui.extensions.rememberVerticalScroll
import org.jetbrains.compose.resources.imageResource
private val uiState = DI.uiState
@ -54,11 +56,11 @@ private val VerticalSpacing = 8.dp
fun SideMenuContent() {
val drawerState = uiState.drawerState.collectAsState().value
val accounts = uiState.banks.collectAsState().value
val accounts = uiState.accounts.collectAsState().value
val coroutineScope = rememberCoroutineScope()
Column(Modifier.fillMaxSize().background(Colors.DrawerContentBackground).verticalScroll(ScrollState(0), enabled = true)) {
Column(Modifier.fillMaxSize().background(Colors.DrawerContentBackground).rememberVerticalScroll()) {
Column(Modifier.fillMaxWidth().height(HeaderHeight.dp).background(HeaderBackground).padding(16.dp)) {
Spacer(Modifier.weight(1f))
@ -66,12 +68,12 @@ fun SideMenuContent() {
Text("Bankmeister", color = Color.White, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp))
Text("Version 1.0.0 Alpha 12", color = Color.LightGray)
Text("Version 1.0.0 Alpha 15", color = Color.LightGray)
}
ItemDivider(color = Colors.DrawerDivider)
Column(Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) {
Column(Modifier.padding(vertical = 24.dp).padding(start = 16.dp, end = 4.dp)) {
Column(Modifier.height(ItemHeight), verticalArrangement = Arrangement.Center) {
Text("Konten", color = textColor)
}
@ -94,19 +96,6 @@ fun SideMenuContent() {
drawerState.close()
}
}
if (accounts.isNotEmpty()) {
Spacer(Modifier.height(VerticalSpacing))
NavigationMenuItem(itemModifier, "Neue Überweisung", textColor, horizontalPadding = ItemHorizontalPadding,
icon = { Icon(Icons.Filled.Add, "Neue Überweisung", Modifier.size(iconSize), tint = textColor) }) {
uiState.showTransferMoneyDialogData.value = ShowTransferMoneyDialogData()
coroutineScope.launch {
drawerState.close()
}
}
}
}
if (accounts.isNotEmpty()) {
@ -123,6 +112,24 @@ fun SideMenuContent() {
drawerState.close()
}
}
NavigationMenuItem(itemModifier, "Appzugang schützen", textColor, horizontalPadding = ItemHorizontalPadding,
icon = { Icon(Icons.Outlined.Key, "Appzugang durch Passwort oder Biometrieeingabe schützen", Modifier.size(iconSize), tint = textColor) }) {
uiState.showProtectAppSettingsScreen.value = true
coroutineScope.launch {
drawerState.close()
}
}
NavigationMenuItem(itemModifier, "Feedback", textColor, horizontalPadding = ItemHorizontalPadding,
icon = { Icon(Icons.AutoMirrored.Filled.Message, "Feedback an die Entwickler geben", Modifier.size(iconSize), tint = textColor) }) {
uiState.showFeedbackScreen.value = true
coroutineScope.launch {
drawerState.close()
}
}
}
}
}

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

View File

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

View File

@ -0,0 +1,22 @@
package net.codinux.banking.ui.composables
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.config.Style
@Composable
fun CloseButton(contentDescription: String = "Dialog schließen", color: Color = Style.ListItemHeaderTextColor, size: Dp = 32.dp, onClick: () -> Unit) {
TextButton(onClick, colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent), contentPadding = PaddingValues(0.dp), modifier = Modifier.size(size)) {
Icon(Icons.Filled.Close, contentDescription = contentDescription, Modifier.size(size), tint = color)
}
}

View File

@ -2,13 +2,13 @@ package net.codinux.banking.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -19,8 +19,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.BankAccessEntity
import net.codinux.banking.persistence.entities.BankAccountEntity
import net.codinux.banking.persistence.entities.BankAccessEntity
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
@ -94,6 +94,8 @@ fun NavigationMenuItem(
bankAccount.balance
} else if (bank != null) {
calculator.calculateBalanceOfBankAccess(bank)
} else if (text == "Alle Konten") {
calculator.calculateBalanceOfAllAccounts(DI.uiState.accounts.value)
} else {
null
}
@ -101,9 +103,20 @@ fun NavigationMenuItem(
if (balance != null) {
Text(
formatUtil.formatAmount(balance, calculator.getTransactionsCurrency(emptyList())),
color = formatUtil.getColorForAmount(balance, showColoredAmounts),
modifier = Modifier.padding(start = 4.dp)
color = if (showColoredAmounts) formatUtil.getColorForAmount(balance, showColoredAmounts) else textColor,
modifier = Modifier.padding(start = 8.dp)
)
}
if (bank != null) {
if (bankAccount == null) {
Column(Modifier.clickable { DI.uiState.showBankSettingsScreenForBank.value = bank }.padding(start = 8.dp).size(24.dp)) {
Icon(Icons.Outlined.Settings, "Zu Kontoeinstellungen wechseln", tint = textColor, modifier = Modifier.size(24.dp))
}
}
}
if (bankAccount != null || bank == null) { // show a place holder to match Settings icon's width
Spacer(Modifier.padding(start = 8.dp).size(24.dp))
}
}
}

View File

@ -6,7 +6,7 @@ import androidx.compose.runtime.*
import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.dialogs.*
import net.codinux.banking.ui.screens.ExportScreen
import net.codinux.banking.ui.screens.*
import net.codinux.banking.ui.state.UiState
private val formatUtil = DI.formatUtil
@ -15,7 +15,16 @@ private val formatUtil = DI.formatUtil
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
val showTransferMoneyDialogData by uiState.showTransferMoneyDialogData.collectAsState()
val showTransferMoneyFromEpcQrCodeScreen by uiState.showTransferMoneyFromEpcQrCodeScreen.collectAsState()
val showCreateEpcQrCodeScreen by uiState.showCreateEpcQrCodeScreen.collectAsState()
val showAccountTransactionDetailsScreenForId by uiState.showAccountTransactionDetailsScreenForId.collectAsState()
val showBankSettingsScreenForBank by uiState.showBankSettingsScreenForBank.collectAsState()
val showBankAccountSettingsScreenForAccount by uiState.showBankAccountSettingsScreenForAccount.collectAsState()
val showExportScreen by uiState.showExportScreen.collectAsState()
val showFeedbackScreen by uiState.showFeedbackScreen.collectAsState()
val showProtectAppSettingsScreen by uiState.showProtectAppSettingsScreen.collectAsState()
val tanChallengeReceived by uiState.tanChallengeReceived.collectAsState()
val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState()
@ -32,10 +41,42 @@ fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
TransferMoneyDialog(data) { uiState.showTransferMoneyDialogData.value = null }
}
if (showTransferMoneyFromEpcQrCodeScreen) {
TransferMoneyFromQrCodeScreen { uiState.showTransferMoneyFromEpcQrCodeScreen.value = false }
}
if (showCreateEpcQrCodeScreen) {
CreateEpcQrCodeScreen { uiState.showCreateEpcQrCodeScreen.value = false }
}
showAccountTransactionDetailsScreenForId?.let { transactionId ->
DI.bankingService.getTransaction(transactionId)?.let { transaction ->
AccountTransactionDetailsScreen(transaction) { uiState.showAccountTransactionDetailsScreenForId.value = null }
}
}
showBankSettingsScreenForBank?.let { bank ->
BankSettingsScreen(bank) { uiState.showBankSettingsScreenForBank.value = null }
}
showBankAccountSettingsScreenForAccount?.let { account ->
BankAccountSettingsScreen(account) { uiState.showBankAccountSettingsScreenForAccount.value = null }
}
if (showExportScreen) {
ExportScreen { uiState.showExportScreen.value = false }
}
if (showFeedbackScreen) {
FeedbackScreen { uiState.showFeedbackScreen.value = false }
}
if (showProtectAppSettingsScreen) {
ProtectAppSettingsDialog(uiState.appSettings.value) { uiState.showProtectAppSettingsScreen.value = false }
}
tanChallengeReceived?.let { tanChallengeReceived ->
EnterTanDialog(tanChallengeReceived) {

View File

@ -0,0 +1,24 @@
package net.codinux.banking.ui.composables.authentification
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Fingerprint
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.model.AuthenticationResult
import net.codinux.banking.ui.service.AuthenticationService
import net.codinux.banking.ui.service.safelyAuthenticateWithBiometrics
@Composable
fun BiometricAuthenticationButton(authenticationResult: (AuthenticationResult) -> Unit) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.Center) {
Button({ AuthenticationService.safelyAuthenticateWithBiometrics(authenticationResult) }, enabled = AuthenticationService.supportsBiometricAuthentication) {
Icon(Icons.Outlined.Fingerprint, "Sich mittels Biometrie authentifizieren", Modifier.size(84.dp))
}
}
}

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.forms.BooleanOption
import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.TransactionsGrouping
@Composable
fun UiSettings(modifier: Modifier, textColor: Color = Color.Unspecified) {

View File

@ -0,0 +1,46 @@
package net.codinux.banking.ui.composables.tan
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.service.tan.Bit
@Composable
fun ChipTanFlickerCodeStripeView(stripe: Bit, width: Dp, showTanGeneratorMarker: Boolean = false) {
Column(Modifier.width(width).fillMaxHeight()) {
val markerHeight = width * 0.5f
val triangleSize = markerHeight.value * LocalDensity.current.density
Column(Modifier.padding(bottom = 4.dp).width(width).height(markerHeight), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom) {
if (showTanGeneratorMarker) {
val path = Path().apply {
// Line to the top-right corner of the triangle
lineTo(triangleSize, 0f)
// Line to the bottom-center point of the triangle
lineTo(triangleSize / 2, triangleSize)
// Close the path (line back to the starting point)
close()
}
Canvas(modifier = Modifier.size(markerHeight)) {
drawPath(path, Color.White, 1f, Fill)
}
}
}
Column(Modifier.fillMaxSize().background(if (stripe.isHigh) Color.White else Color.Black)) {
}
}
}

View File

@ -0,0 +1,193 @@
package net.codinux.banking.ui.composables.tan
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.client.model.tan.FlickerCode
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.Config.NewLine
import net.codinux.banking.ui.service.tan.Bit
import net.codinux.banking.ui.service.tan.FlickerCodeAnimator
import net.codinux.banking.ui.service.tan.Step
private const val FrequencyStepSize = 2
private val DefaultStripesHeight = 240.dp
private val StripesHeightStepSize = 7.dp
private val StripesWidthStepSize = 2.dp
private val SpaceBetweenStripesStepSize = 1.dp
private val bankingService = DI.bankingService
@Composable
fun ChipTanFlickerCodeView(flickerCode: FlickerCode, textColor: Color = Color.Black) {
val animator = remember { FlickerCodeAnimator() }
val flickerCodeSettings by remember { mutableStateOf(bankingService.getImageSettingsOrCreateDefault("FlickerCode", 240)) }
val sizeFactor by remember { mutableStateOf(
if (flickerCodeSettings.height == 240) {
1
} else {
val diff = flickerCodeSettings.height.dp - DefaultStripesHeight
(diff / StripesHeightStepSize).toInt()
}
) }
var stripesHeight by remember { mutableStateOf(DefaultStripesHeight + StripesHeightStepSize.times(sizeFactor)) }
var stripesWidth by remember { mutableStateOf(45.dp + StripesWidthStepSize.times(sizeFactor)) }
var spaceBetweenStripes by remember { mutableStateOf(15.dp + SpaceBetweenStripesStepSize.times(sizeFactor)) }
var frequency by remember { mutableStateOf(flickerCodeSettings.frequency ?: FlickerCodeAnimator.DefaultFrequency) }
var isPaused by remember { mutableStateOf(false) }
var step by remember { mutableStateOf(Step(Bit.High, Bit.High, Bit.High, Bit.High, Bit.High)) }
val coroutineScope = rememberCoroutineScope()
fun setSize(width: Dp, height: Dp, spaceBetween: Dp) {
stripesWidth = width
stripesHeight = height
spaceBetweenStripes = spaceBetween
flickerCodeSettings.height = height.value.toInt()
bankingService.saveImageSettingsDebounced(flickerCodeSettings, coroutineScope)
}
fun decreaseSize() {
if (spaceBetweenStripes - SpaceBetweenStripesStepSize > 0.dp) {
setSize(
stripesWidth - StripesWidthStepSize,
stripesHeight - StripesHeightStepSize,
spaceBetweenStripes - SpaceBetweenStripesStepSize
)
}
}
fun increaseSize() { // set also an upper limit to size?
setSize(
stripesWidth + StripesWidthStepSize,
stripesHeight + StripesHeightStepSize,
spaceBetweenStripes + SpaceBetweenStripesStepSize
)
}
fun setFrequency(newFrequency: Int) {
frequency = newFrequency
animator.setFrequency(newFrequency)
flickerCodeSettings.frequency = newFrequency
bankingService.saveImageSettingsDebounced(flickerCodeSettings, coroutineScope)
}
fun decreaseFrequency() {
if (frequency - FrequencyStepSize >= FlickerCodeAnimator.MinFrequency) {
setFrequency(frequency - FrequencyStepSize)
}
}
fun increaseFrequency() {
if (frequency + FrequencyStepSize <= FlickerCodeAnimator.MaxFrequency) {
setFrequency(frequency + FrequencyStepSize)
}
}
fun toggleIsPaused() {
isPaused = !isPaused
if (isPaused) {
animator.pause()
} else {
animator.resume()
}
}
Column(Modifier.fillMaxWidth()) {
flickerCode.decodingError?.let {
Text("Hier sollte eigentlich ein FlickerCode stehen, dieser konnte jedoch nicht dekodiert werden:${NewLine}${flickerCode.decodingError}", color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
}
flickerCode.parsedDataSet?.let {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
ImageSizeControls(true, true, textColor, { decreaseSize() }, { increaseSize() })
Spacer(Modifier.width(16.dp))
Text("Geschw.", color = textColor)
IconButton({ decreaseFrequency() }, enabled = frequency - FrequencyStepSize > 0) {
Icon(Icons.AutoMirrored.Filled.DirectionsWalk, contentDescription = "Frequenz verkleinern", Modifier.size(28.dp), tint = textColor)
}
IconButton({ increaseFrequency() }, enabled = frequency - FrequencyStepSize > 0) {
Icon(Icons.AutoMirrored.Filled.DirectionsRun, contentDescription = "Frequenz vergrößern", Modifier.size(28.dp), tint = textColor)
}
IconButton({ toggleIsPaused() }) {
if (isPaused) {
Icon(Icons.Filled.PlayArrow, "FlickerCode Animation wieder starten", Modifier.size(28.dp), tint = textColor)
} else {
Icon(Icons.Filled.Pause, "FlickerCode Animation pausieren", Modifier.size(28.dp), tint = textColor)
}
}
}
Row(Modifier.background(Color.Black).padding(vertical = 20.dp), verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.fillMaxWidth().height(stripesHeight).background(Color.Black), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
ChipTanFlickerCodeStripeView(step.bit1, stripesWidth, true)
Spacer(Modifier.width(spaceBetweenStripes))
ChipTanFlickerCodeStripeView(step.bit2, stripesWidth)
Spacer(Modifier.width(spaceBetweenStripes))
ChipTanFlickerCodeStripeView(step.bit3, stripesWidth)
Spacer(Modifier.width(spaceBetweenStripes))
ChipTanFlickerCodeStripeView(step.bit4, stripesWidth)
Spacer(Modifier.width(spaceBetweenStripes))
ChipTanFlickerCodeStripeView(step.bit5, stripesWidth, true)
}
}
}
}
DisposableEffect(animator) {
animator.setFrequency(frequency)
animator.animateFlickerCode(flickerCode, coroutineScope) {
step = it
}
onDispose {
animator.stop()
}
}
}

View File

@ -0,0 +1,28 @@
package net.codinux.banking.ui.composables.tan
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ZoomIn
import androidx.compose.material.icons.filled.ZoomOut
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun ImageSizeControls(decreaseEnabled: Boolean, increaseEnabled: Boolean, textColor: Color = Color.Black, onDecreaseImageSize: () -> Unit, onIncreaseImageSize: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Größe", color = textColor, modifier = Modifier.padding(end = 6.dp))
TextButton({ onDecreaseImageSize() }, enabled = decreaseEnabled, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.ZoomOut, contentDescription = "Bild verkleinern", Modifier.size(28.dp), tint = textColor)
}
TextButton({ onIncreaseImageSize() }, enabled = increaseEnabled, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.ZoomIn, contentDescription = "Bild vergrößern", Modifier.size(28.dp), tint = textColor)
}
}
}

View File

@ -0,0 +1,54 @@
package net.codinux.banking.ui.composables.tan
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.service.createImageBitmap
private val bankingService = DI.bankingService
@Composable
fun ImageView(
imageBytes: ByteArray,
imageSettingsId: String,
contentDescription: String,
initialImageHeight: Int = 300,
minImageHeight: Int = 0,
maxImageHeight: Int? = null,
changeImageSizeStep: Int = 25,
textColor: Color = Colors.MaterialThemeTextColor,
) {
val imageSettings = bankingService.getImageSettingsOrCreateDefault(imageSettingsId, initialImageHeight)
var imageHeight by remember { mutableStateOf(imageSettings.height) }
val coroutineScope = rememberCoroutineScope()
fun changeImageSize(by: Int) {
imageHeight += by
imageSettings.height = imageHeight
bankingService.saveImageSettingsDebounced(imageSettings, coroutineScope)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
ImageSizeControls(imageHeight > minImageHeight, maxImageHeight == null || imageHeight < maxImageHeight, textColor, { changeImageSize(-changeImageSizeStep) }) { changeImageSize(changeImageSizeStep) }
}
Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Image(createImageBitmap(imageBytes), contentDescription, Modifier.height(imageHeight.dp), contentScale = ContentScale.FillHeight)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -22,6 +22,9 @@ object Colors {
val BackgroundColorLight = Color("#FFFFFF")
val MaterialThemeTextColor = Color(0xFF4F4F4F) // to match dialog's text color of Material theme
val DrawerContentBackground = BackgroundColorDark
val DrawerPrimaryText = PrimaryTextColorDark
@ -34,6 +37,16 @@ object Colors {
val CodinuxSecondaryColor = Color(251, 187, 33)
val FormLabelTextColor = Color(0xFF494949)
val FormValueTextColor = Color(0xFF999999)
val FormListItemTextColor = FormLabelTextColor
val DestructiveColor = Color(0xFFff3b30)
val Zinc100 = Color(244, 244, 245)
val Zinc100_50 = Zinc100.copy(alpha = 0.5f)

View File

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

View File

@ -1,7 +1,9 @@
package net.codinux.banking.ui.config
import net.codinux.banking.client.model.BankAccountType
import net.codinux.banking.client.model.tan.ActionRequiringTan
import net.codinux.banking.ui.model.TransactionsGrouping
import net.codinux.banking.ui.model.settings.TransactionsGrouping
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
object Internationalization {
@ -11,6 +13,12 @@ object Internationalization {
const val ErrorTransferMoney = "Überweisung konnte nicht ausgeführt werden"
const val ErrorReadEpcQrCode = "Überweisungsdaten konnten nicht aus dem QR Code ausgelesen werden"
const val ErrorSaveToDatabase = "Daten konnten nicht in der Datenbank gespeichert werden"
const val ErrorBiometricAuthentication = "Biometrische Authentifizierung fehlgeschlagen"
fun getTextForActionRequiringTan(action: ActionRequiringTan): String = when (action) {
ActionRequiringTan.GetAnonymousBankInfo,
@ -30,4 +38,23 @@ object Internationalization {
TransactionsGrouping.None -> "Nicht gruppieren"
}
fun translate(accountType: BankAccountType): String = when (accountType) {
BankAccountType.CheckingAccount -> "Girokonto"
BankAccountType.SavingsAccount -> "Sparkonto"
BankAccountType.FixedTermDepositAccount -> "Festgeldkonto"
BankAccountType.SecuritiesAccount -> "Wertpapierdepot"
BankAccountType.LoanAccount -> "Darlehenskonto"
BankAccountType.CreditCardAccount -> "Kreditkartenkonto"
BankAccountType.FundDeposit -> "Fondsdepot"
BankAccountType.BuildingLoanContract -> "Bausparvertrag"
BankAccountType.InsuranceContract -> "Versicherungsvertrag"
BankAccountType.Other -> "Sonstige"
}
fun translate(authenticationMethod: AppAuthenticationMethod): String = when (authenticationMethod) {
AppAuthenticationMethod.None -> "Ungeschützt"
AppAuthenticationMethod.Password -> "Passwort"
AppAuthenticationMethod.Biometric -> "Biometrie"
}
}

View File

@ -19,6 +19,15 @@ object Style {
val ListItemHeaderWeight = FontWeight.Medium // couldn't believe it, the FontWeights look different on Desktop and Android
val FabSize = 56.dp
val SmallFabSize = 46.dp
val FabSpacing = 16.dp
val FabMenuSpacing = FabSpacing / 2
val DividerThickness = 1.dp
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
package net.codinux.banking.ui.dialogs
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ConfirmDialog(
text: String,
title: String? = null,
confirmButtonTitle: String = "Ja",
dismissButtonTitle: String = "Nein",
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
BaseDialog(
title = title ?: "",
centerTitle = true,
confirmButtonTitle = confirmButtonTitle,
dismissButtonTitle = dismissButtonTitle,
onDismiss = { onDismiss() },
onConfirm = { onConfirm(); onDismiss() }
) {
Text(text, textAlign = TextAlign.Center, lineHeight = 22.sp, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp))
}
}

View File

@ -1,33 +1,34 @@
package net.codinux.banking.ui.dialogs
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ZoomIn
import androidx.compose.material.icons.filled.ZoomOut
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.codinux.banking.client.model.tan.*
import net.codinux.banking.client.model.tan.ActionRequiringTan
import net.codinux.banking.client.model.tan.AllowedTanFormat
import net.codinux.banking.client.model.tan.EnterTanResult
import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.composables.tan.ChipTanFlickerCodeView
import net.codinux.banking.ui.composables.tan.ImageView
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.forms.CaptionText
import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.Config.NewLine
import net.codinux.banking.ui.model.TanChallengeReceived
import net.codinux.banking.ui.model.error.ErroneousAction
import net.codinux.banking.ui.service.createImageBitmap
import net.codinux.log.Log
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@ -41,9 +42,7 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
val isNotADecoupledTanMethod = !!!isDecoupledMethod
var tanImageHeight by remember { mutableStateOf(250) }
val minTanImageHeight = 100
val maxTanImageHeight = 500
var showSelectingTanMediumNotImplementedWarning by remember { mutableStateOf(false) }
val textFieldFocus = remember { FocusRequester() }
@ -98,10 +97,10 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
Text("${challenge.bank.bankName}, Nutzer ${challenge.bank.loginName}${challenge.account?.let { ", Konto ${it.productName ?: it.identifier}" } ?: ""}")
}
Text(
"TAN benötigt ${Internationalization.getTextForActionRequiringTan(challenge.forAction)}",
Modifier.padding(top = 6.dp)
)
Row(Modifier.padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Text("TAN benötigt ")
Text(Internationalization.getTextForActionRequiringTan(challenge.forAction), fontWeight = FontWeight.Bold)
}
}
@ -110,19 +109,9 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
"TAN Verfahren",
challenge.availableTanMethods.sortedBy { it.identifier },
challenge.selectedTanMethod,
{ tanMethod ->
if (tanMethod.type != TanMethodType.ChipTanFlickercode) {
tanChallengeReceived.callback(EnterTanResult(null, tanMethod))
}
},
{ tanMethod -> tanChallengeReceived.callback(EnterTanResult(null, tanMethod)) },
{ it.displayName }
) { tanMethod ->
if (tanMethod.type == TanMethodType.ChipTanFlickercode) {
Text(tanMethod.displayName + " (noch nicht implementiert)", color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled))
} else {
Text(tanMethod.displayName)
}
}
) { tanMethod -> Text(tanMethod.displayName) }
}
if (challenge.availableTanMedia.isNotEmpty()) {
@ -131,39 +120,35 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
"TAN Medium",
challenge.availableTanMedia.sortedBy { it.status }.map { it.displayName },
challenge.selectedTanMedium?.displayName ?: "<Keines ausgewählt>",
{ Log.info { "User selected TanMedium $it" } }, // TODO: change TanMethod
{ showSelectingTanMediumNotImplementedWarning = true }, // TODO: change TanMedium
{ it }
)
}
if (showSelectingTanMediumNotImplementedWarning) {
CaptionText("Es tut uns Leid, aber das Ändern des TAN Mediums ist gegenwärtig noch nicht implementiert", Colors.DestructiveColor, Arrangement.Start)
}
}
if (challenge.tanImage != null || challenge.flickerCode != null) {
Column(Modifier.fillMaxWidth().padding(top = 6.dp)) {
if (challenge.flickerCode != null) {
Text("Es tut uns Leid, für die TAN müsste ein Flickercode angezeigt werden, was wir noch nicht implementiert haben.")
Text("Bitte wählen Sie ein anderes TAN Verfahren, z. B. chipTAN-QrCode oder manuelle TAN Eingabe wie chipTAN manuell.", Modifier.padding(top = 6.dp))
val textColor = Colors.MaterialThemeTextColor // to match dialog's text color of Material theme
challenge.flickerCode?.let { flickerCode ->
ChipTanFlickerCodeView(flickerCode, textColor)
}
challenge.tanImage?.let { tanImage ->
if (tanImage.decodingSuccessful) {
val imageBytes = Base64.decode(tanImage.imageBytesBase64)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text("Größe")
Spacer(Modifier.width(6.dp))
TextButton({ tanImageHeight -= 25}, enabled = tanImageHeight > minTanImageHeight, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.ZoomOut, contentDescription = "Bild mit enkodierter TAN verkleiner", Modifier.size(28.dp))
}
Spacer(Modifier.width(6.dp))
TextButton({ tanImageHeight += 25}, enabled = tanImageHeight < maxTanImageHeight, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) {
Icon(Icons.Filled.ZoomIn, contentDescription = "Bild mit enkodierter TAN vergrößern", Modifier.size(28.dp))
}
tanImage.decodingError?.let {
Text("Hier sollte eigentlich das TAN Bild angezeigt werden, dieses konnte jedoch nicht dekodiert werden:$NewLine${tanImage.decodingError}", color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Image(createImageBitmap(imageBytes), "Bild mit enkodierter TAN", Modifier.height(tanImageHeight.dp), contentScale = ContentScale.FillHeight)
}
tanImage.imageBytesBase64?.let { imageBytesBase64 ->
val imageBytes = Base64.decode(imageBytesBase64)
// if it becomes necessary may also add the bank to ImageSettings.id to make ImageSettings bank specific
ImageView(imageBytes, challenge.selectedTanMethod.type.toString(), "Bild mit enkodierter TAN", 250, 100, 500, textColor = textColor)
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,11 @@
package net.codinux.banking.ui.extensions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
val KeyboardOptions.Companion.ImeNext: KeyboardOptions
get() = KeyboardOptions(imeAction = ImeAction.Next)
val KeyboardOptions.Companion.ImeDone: KeyboardOptions
get() = KeyboardOptions(imeAction = ImeAction.Done)

View File

@ -0,0 +1,49 @@
package net.codinux.banking.ui.extensions
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.systemPaddings
import net.codinux.log.Log
fun Modifier.verticalScroll() = this.verticalScroll(ScrollState(0), enabled = true)
@Composable
fun Modifier.rememberVerticalScroll() = this.verticalScroll(rememberScrollState())
fun Modifier.horizontalScroll() = this.horizontalScroll(ScrollState(0), enabled = true)
@Composable
fun Modifier.rememberHorizontalScroll() = this.horizontalScroll(rememberScrollState())
@Composable
// we need to support three different cases:
// - normal, non fullscreen dialog, either useMoreThanPlatformDefaultWidthOnMobile is false or soft keyboard is hidden -> apply default vertical padding
// - normal, non fullscreen dialog, useMoreThanPlatformDefaultWidthOnMobile is true and soft keyboard is visible = applyPlatformPadding == true -> on iOS apply platform padding as
// otherwise dialog title gets hidden by upper system bar, on all other platforms default vertical padding
// - fullscreen dialog -> on iOS apply platform padding as otherwise dialog title gets hidden by upper system bar, on all other platforms default vertical padding
fun Modifier.applyPlatformSpecificPaddingIf(applyPlatformPadding: Boolean, minVerticalPadding: Dp = 0.dp): Modifier =
if (applyPlatformPadding) {
this.applyPlatformSpecificPadding(minVerticalPadding)
} else if (minVerticalPadding > 0.dp) {
this.padding(vertical = minVerticalPadding)
} else {
this
}
@Composable
fun Modifier.applyPlatformSpecificPadding(minVerticalPadding: Dp = 0.dp): Modifier {
val systemPaddings = systemPaddings()
return this.padding(
top = maxOf(minVerticalPadding, systemPaddings.calculateTopPadding()),
bottom = maxOf(minVerticalPadding, systemPaddings.calculateBottomPadding())
).also {
Log.info { "Applied padding: ${systemPaddings.calculateTopPadding()}, ${systemPaddings.calculateBottomPadding()}" }
}
}

View File

@ -30,6 +30,7 @@ fun <T> AutocompleteTextField(
modifier: Modifier = Modifier,
textFieldFocus: FocusRequester = remember { FocusRequester() },
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onEnterPressed: (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
fetchSuggestions: suspend (query: String) -> Collection<T> = { emptyList() },
suggestionContent: @Composable (T) -> Unit
@ -102,7 +103,8 @@ fun <T> AutocompleteTextField(
)
}
},
leadingIcon = leadingIcon
leadingIcon = leadingIcon,
onEnterPressed = onEnterPressed
)
// due to a bug (still not fixed since 2021) in ExposedDropdownMenu its popup has a maximum width of 800 pixel / 320dp which is too less to fit

View File

@ -0,0 +1,23 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun CaptionText(text: String, color: Color = Color.Unspecified, horizontalArrangement: Arrangement.Horizontal = Arrangement.End) {
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = horizontalArrangement) {
Text(
text = text,
style = MaterialTheme.typography.caption,
color = color
)
}
}

View File

@ -0,0 +1,56 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.codinux.banking.ui.config.Colors
@Composable
fun FormListItem(label: String, itemHeight: Dp = 32.dp, onClick: (() -> Unit)? = null) {
FormListItemImpl(label, itemHeight = itemHeight, onClick = onClick)
}
@Composable
fun SelectableFormListItem(
label: String,
isSelected: Boolean = false,
selectedIconContentDescription: String? = null,
itemHeight: Dp = 32.dp,
onClick: (() -> Unit)? = null
) {
FormListItemImpl(label, true, isSelected, selectedIconContentDescription, itemHeight, onClick)
}
@Composable
private fun FormListItemImpl(
label: String,
isSelectable: Boolean = false,
isSelected: Boolean = false,
selectedIconContentDescription: String? = null,
itemHeight: Dp = 32.dp,
onClick: (() -> Unit)? = null
) {
Row(Modifier.fillMaxWidth().height(itemHeight).clickable { onClick?.invoke() }.padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
if (isSelectable) {
Column(Modifier.padding(end = 8.dp).size(24.dp)) {
if (isSelected) {
Icon(Icons.Outlined.Check, selectedIconContentDescription ?: "Item ist ausgewählt", tint = Colors.FormListItemTextColor)
}
}
}
Text(label, color = Colors.FormListItemTextColor, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View File

@ -0,0 +1,38 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.codinux.banking.ui.config.Colors
@Composable
fun LabelledValue(label: String, value: String?, valueTextColor: Color? = null, labelMaxLines: Int = 1) {
if (value != null) {
Column(Modifier.fillMaxWidth()) {
Text(
text = label,
modifier = Modifier.padding(top = 12.dp, bottom = 2.dp),
fontSize = 15.sp,
color = Colors.FormLabelTextColor,
maxLines = labelMaxLines,
overflow = TextOverflow.Ellipsis
)
Text(
text = value,
modifier = Modifier.padding(bottom = 4.dp),
fontSize = 15.sp,
color = valueTextColor ?: Colors.FormValueTextColor
)
}
}
}

View File

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

View File

@ -0,0 +1,28 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.codinux.banking.ui.config.Colors
@Composable
fun SectionHeader(title: String, topPadding: Boolean = true) {
Text(
text = title,
modifier = Modifier.fillMaxWidth().let {
if (topPadding) {
it.padding(top = 24.dp)
} else {
it
}
},
color = Colors.CodinuxSecondaryColor,
fontSize = 16.sp
)
}

View File

@ -0,0 +1,68 @@
package net.codinux.banking.ui.forms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.codinux.banking.ui.config.Colors
@Composable
fun <T> SegmentedControl(
options: Collection<T>,
selectedOption: T,
modifier: Modifier = Modifier,
color: Color = Colors.Accent,
cornerSize: Dp = 8.dp,
getOptionDisplayText: ((T) -> String)? = null,
onOptionSelected: (T) -> Unit
) {
Row(horizontalArrangement = Arrangement.Center) {
Row(modifier.height(48.dp).border(2.dp, color, RoundedCornerShape(cornerSize))) {
options.forEachIndexed { index, option ->
val isSelected = option == selectedOption
val backgroundColor = if (isSelected) color else Color.Transparent
val textColor = if (isSelected) Color.White else color
Box(
modifier = Modifier
.clickable { onOptionSelected(option) }
.fillMaxHeight()
.weight(1f)
.let {
if (index == 0) {
it.background(backgroundColor, RoundedCornerShape(topStart = cornerSize, bottomStart = cornerSize))
} else if (index == options.size - 1) {
it.background(backgroundColor, RoundedCornerShape(topEnd = cornerSize, bottomEnd = cornerSize))
} else {
it.background(backgroundColor)
}
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = getOptionDisplayText?.invoke(option) ?: option.toString(),
color = textColor,
textAlign = TextAlign.Center
)
}
if (index < options.size - 1) {
Divider(Modifier.fillMaxHeight().width(1.dp), color = color)
}
}
}
}
}

View File

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

View File

@ -0,0 +1,17 @@
package net.codinux.banking.ui.model
class AuthenticationResult(
val successful: Boolean,
val error: String? = null
) {
override fun toString(): String {
return if (successful) {
"Successful"
}
else {
"Error occurred: $error"
}
}
}

View File

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

View File

@ -1,27 +0,0 @@
package net.codinux.banking.ui.model
import net.codinux.banking.client.model.BankingGroup
import kotlinx.serialization.Serializable
@Serializable
class BankInfo(
val name: String,
val domesticBankCode: String,
val bic: String = "",
val postalCode: String,
val city: String,
val pinTanAddress: String? = null,
val pinTanVersion: String? = null,
val bankingGroup: BankingGroup? = null,
val branchesInOtherCities: List<String> = listOf() // to have only one entry per bank its branches' cities are now stored in branchesInOtherCities so that branches' cities are still searchable
) {
val supportsPinTan: Boolean
get() = pinTanAddress.isNullOrEmpty() == false
val supportsFinTs3_0: Boolean
get() = pinTanVersion == "FinTS V3.0"
override fun toString() = "$domesticBankCode $name $city"
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,161 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import net.codinux.banking.client.model.isNegative
import net.codinux.banking.persistence.entities.AccountTransactionEntity
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.rememberVerticalScroll
import net.codinux.banking.ui.forms.LabelledValue
import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.ui.forms.SectionHeader
private val formatUtil = DI.formatUtil
@Composable
fun AccountTransactionDetailsScreen(transaction: AccountTransactionEntity, onClosed: () -> Unit) {
val isExpense = transaction.amount.isNegative
val account = DI.uiState.banks.value.firstOrNull { it.id == transaction.bankId }?.accounts?.firstOrNull { it.id == transaction.accountId }
val accountCurrency = account?.currency ?: transaction.currency // transaction.currency just as fallback
val showColoredAmounts = DI.uiSettings.showColoredAmounts.collectAsState()
val hasDetailedValues = transaction.customerReference != null ||
transaction.endToEndReference != null || transaction.mandateReference != null
|| transaction.creditorIdentifier != null || transaction.originatorsIdentificationCode != null
|| transaction.compensationAmount != null || transaction.originalAmount != null
|| transaction.deviantOriginator != null || transaction.deviantRecipient != null
|| transaction.referenceWithNoSpecialType != null
|| transaction.journalNumber != null || transaction.textKeyAddition != null
var enteredOtherPartyName by remember { mutableStateOf(transaction.displayedOtherPartyName ?: "") }
var enteredReference by remember { mutableStateOf(transaction.displayedReference ?: "") }
var enteredNotes by remember { mutableStateOf(transaction.notes ?: "") }
val hasDataChanged by remember(enteredOtherPartyName, enteredReference, enteredNotes) {
mutableStateOf(
(enteredOtherPartyName != transaction.userSetOtherPartyName && (transaction.userSetOtherPartyName?.isNotBlank() == true || enteredOtherPartyName.isNotBlank()))
|| (enteredReference != transaction.userSetReference && (transaction.userSetReference?.isNotBlank() == true || enteredReference.isNotBlank()))
|| (enteredNotes != transaction.notes && enteredNotes.isNotBlank())
)
}
val coroutineScope = rememberCoroutineScope()
fun saveChanges() {
coroutineScope.launch {
DI.bankingService.updateAccountTransactionEntity(transaction, enteredOtherPartyName.takeUnless { it.isBlank() }, enteredReference.takeUnless { it.isBlank() }, enteredNotes.takeUnless { it.isBlank() })
}
}
FullscreenViewBase(
"Umsatzdetails",
confirmButtonTitle = "Speichern",
confirmButtonEnabled = hasDataChanged,
showDismissButton = true,
onConfirm = { saveChanges() },
onClosed = onClosed
) {
SelectionContainer {
Column(Modifier.fillMaxSize().rememberVerticalScroll()) {
Column(Modifier.fillMaxWidth()) {
SectionHeader(if (isExpense) "Empfänger*in" else "Zahlende*r", false)
OutlinedTextField(
label = { Text("Name") },
value = enteredOtherPartyName,
onValueChange = { enteredOtherPartyName = it },
modifier = Modifier.fillMaxWidth()
)
LabelledValue("BIC", transaction.otherPartyBankId ?: "")
LabelledValue("IBAN", transaction.otherPartyAccountId ?: "")
}
Column(Modifier.fillMaxWidth()) {
SectionHeader("Betrag, Datum und Verwendungszweck")
LabelledValue("Betrag", formatUtil.formatAmount(transaction.amount, transaction.currency),
formatUtil.getColorForAmount(transaction.amount, showColoredAmounts.value))
LabelledValue("Buchungstext", transaction.postingText ?: "")
OutlinedTextField(
label = { Text("Verwendungszweck") },
value = enteredReference,
onValueChange = { enteredReference = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
LabelledValue("Buchungsdatum", formatUtil.formatDate(transaction.bookingDate))
LabelledValue("Wertstellungsdatum", formatUtil.formatDate(transaction.valueDate))
transaction.openingBalance?.let {
LabelledValue("Tagesanfangssaldo", formatUtil.formatAmount(it, accountCurrency))
}
transaction.closingBalance?.let {
LabelledValue("Tagesendsaldo", formatUtil.formatAmount(it, accountCurrency))
}
OutlinedTextField(
label = { Text("Notizen") },
value = enteredNotes,
onValueChange = { enteredNotes = it },
singleLine = false,
minLines = 2,
maxLines = 3,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
}
if (hasDetailedValues) {
Column(Modifier.fillMaxWidth()) {
SectionHeader("(Lastschrift-)Details")
LabelledValue("Kundenreferenz", transaction.customerReference)
LabelledValue("Bankreferenz", transaction.bankReference)
LabelledValue("Währungsart und Umsatzbetrag in Ursprungswährung", transaction.furtherInformation)
LabelledValue("Ende-zu-Ende Referenz", transaction.endToEndReference)
LabelledValue("Mandatsreferenz", transaction.mandateReference)
LabelledValue("Gläubiger-Identifikationsnummer", transaction.creditorIdentifier)
LabelledValue("Kennung des Auftraggebers", transaction.originatorsIdentificationCode)
LabelledValue("Betrag der ursprünglichen Lastschrift", transaction.originalAmount)
LabelledValue("Rücklastschrift Auslagenersatz und Bearbeitungsprovision", transaction.compensationAmount)
LabelledValue("Abweichender Überweisender oder Zahlungsempfänger", transaction.deviantOriginator)
LabelledValue("Abweichender Zahlungsempfänger oder Zahlungspflichtiger", transaction.deviantRecipient)
LabelledValue("Verwendungszweck ohne spezielle Bedeutung", transaction.referenceWithNoSpecialType)
LabelledValue("Auszugsnummer", transaction.statementNumber?.toString())
LabelledValue("Blattnummer", transaction.sheetNumber?.toString())
LabelledValue("Primanoten-Nr.", transaction.journalNumber)
// LabelledValue("Kundenreferenz", transaction.textKeyAddition)
LabelledValue("Referenznummer", transaction.orderReferenceNumber)
LabelledValue("Bezugsreferenz", transaction.referenceNumber)
}
}
}
}
}
}

View File

@ -0,0 +1,100 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import net.codinux.banking.persistence.entities.BankAccountEntity
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.*
@Composable
fun BankAccountSettingsScreen(account: BankAccountEntity, onClosed: () -> Unit) {
var enteredAccountName by remember { mutableStateOf(account.displayName) }
var selectedHideAccount by remember { mutableStateOf(account.hideAccount) }
var selectedIncludeInAutomaticAccountsUpdate by remember { mutableStateOf(account.includeInAutomaticAccountsUpdate) }
val hasDataChanged by remember(enteredAccountName, selectedHideAccount, selectedIncludeInAutomaticAccountsUpdate) {
mutableStateOf(
enteredAccountName != account.displayName
|| selectedHideAccount != account.hideAccount
|| selectedIncludeInAutomaticAccountsUpdate != account.includeInAutomaticAccountsUpdate
)
}
val coroutineScope = rememberCoroutineScope()
fun saveChanges() {
coroutineScope.launch {
DI.bankingService.updateAccount(account, enteredAccountName, selectedHideAccount, selectedIncludeInAutomaticAccountsUpdate)
}
}
FullscreenViewBase(
account.displayName,
confirmButtonTitle = "Speichern",
confirmButtonEnabled = hasDataChanged,
showDismissButton = true,
onConfirm = { saveChanges() },
onClosed = onClosed
) {
Column(Modifier.fillMaxSize().verticalScroll()) {
Column {
SectionHeader("Einstellungen", false)
OutlinedTextField(
label = { Text("Name") },
value = enteredAccountName,
onValueChange = { enteredAccountName = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp)
)
BooleanOption("Bei Kontoaktualisierung einbeziehen (autom. Kontoaktualisierung noch nicht umgesetzt)", selectedIncludeInAutomaticAccountsUpdate) { selectedIncludeInAutomaticAccountsUpdate = it }
BooleanOption("Konto ausblenden", selectedHideAccount) { selectedHideAccount = it }
}
SelectionContainer {
Column {
SectionHeader("Kontodaten") // TODO: add a share icon to copy data
LabelledValue("Kontoinhaber", account.accountHolderName)
LabelledValue("Kontonummer", account.identifier)
LabelledValue("Unterkontenmerkmal", account.subAccountNumber)
LabelledValue("IBAN", account.iban ?: "")
LabelledValue("Typ", Internationalization.translate(account.type))
LabelledValue("Anzahl Tage, für die Umsätze auf dem Server vorgehalten werden", account.serverTransactionsRetentionDays?.toString() ?: "<unbekannt>", labelMaxLines = 2)
LabelledValue("Umsätze zum letzten Mal abgerufen am", account.lastAccountUpdateTime?.let { DI.formatUtil.formatDateTime(it) } ?: "<Noch nicht abgerufen>" )
}
}
Column {
SectionHeader("Unterstützt")
Column(Modifier.padding(top = 8.dp)) {
SelectableFormListItem("Kontostand abrufen", account.supportsBalanceRetrieval, "Unterstützt das Abrufen des Kontostandes")
SelectableFormListItem("Kontoumsätze abrufen", account.supportsTransactionRetrieval, "Unterstützt das Abrufen der Kontoumsätze")
SelectableFormListItem("Überweisen", account.supportsMoneyTransfer, "Unterstützt Überweisungen")
SelectableFormListItem("Echtzeitüberweisung", account.supportsInstantTransfer, "Unterstützt Echtzeitüberweisungen")
}
}
}
}
}

View File

@ -0,0 +1,150 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import net.codinux.banking.persistence.entities.BankAccessEntity
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.dialogs.ConfirmDialog
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.*
import net.codinux.banking.ui.model.Config.NewLine
@Composable
fun BankSettingsScreen(bank: BankAccessEntity, onClosed: () -> Unit) {
var enteredBankName by remember { mutableStateOf(bank.displayName) }
var enteredLoginName by remember { mutableStateOf(bank.loginName) }
var enteredPassword by remember { mutableStateOf(bank.password ?: "") }
var showDeleteBankAccessConfirmationDialog by remember { mutableStateOf(false) }
val hasDataChanged by remember(enteredBankName, enteredLoginName, enteredPassword) {
mutableStateOf(
(enteredBankName != bank.bankName && (bank.userSetDisplayName == null || enteredBankName != bank.userSetDisplayName))
|| (enteredLoginName != bank.loginName && enteredLoginName.isNotBlank())
|| (enteredPassword != bank.password && enteredPassword.isNotBlank())
)
}
val coroutineScope = rememberCoroutineScope()
fun saveChanges() {
coroutineScope.launch {
DI.bankingService.updateBank(bank, enteredLoginName, enteredPassword, enteredBankName.takeUnless { it.isBlank() })
}
}
if (showDeleteBankAccessConfirmationDialog) {
ConfirmDialog(
title = "${bank.displayName} wirklich löschen?",
text = "Dadurch werden auch alle zum Konto gehörenden Daten wie seine Kontoumsätze unwiderruflich gelöscht.${NewLine}Die Daten können nicht widerhergestellt werden.",
onDismiss = { showDeleteBankAccessConfirmationDialog = false },
onConfirm = {
coroutineScope.launch {
DI.bankingService.deleteBank(bank)
}
onClosed()
}
)
}
FullscreenViewBase(
bank.displayName,
confirmButtonTitle = "Speichern",
confirmButtonEnabled = hasDataChanged,
showDismissButton = true,
onConfirm = { saveChanges() },
onClosed = onClosed
) {
Column(Modifier.fillMaxSize().verticalScroll()) {
Column {
OutlinedTextField(
label = { Text("Name") },
value = enteredBankName,
onValueChange = { enteredBankName = it },
modifier = Modifier.fillMaxWidth()
)
SectionHeader("Online-Banking Zugangsdaten")
OutlinedTextField(
label = { Text("Login Name") },
value = enteredLoginName,
onValueChange = { enteredLoginName = it },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
)
PasswordTextField(
password = enteredPassword,
onChange = { enteredPassword = it }
)
}
Column {
SectionHeader("Konten")
Column(Modifier.padding(top = 8.dp)) {
bank.accountsSorted.forEach { account ->
FormListItem(account.displayName, itemHeight = 42.dp) {
DI.uiState.showBankAccountSettingsScreenForAccount.value = account
}
}
}
}
Column {
SectionHeader("Bankdaten")
LabelledValue("Bankleitzahl", bank.domesticBankCode)
LabelledValue("BIC", bank.bic ?: "")
LabelledValue("Kontoinhaber", bank.customerName)
LabelledValue("FinTS Server", bank.serverAddress)
}
Column {
SectionHeader("TAN Verfahren")
Column(Modifier.padding(top = 8.dp)) {
bank.tanMethodsSorted.forEach { tanMethod ->
SelectableFormListItem(tanMethod.displayName, tanMethod == bank.selectedTanMethod, "TAN Verfahren ist ausgewähltes TAN Verfahren")
}
}
}
if (bank.tanMedia.isNotEmpty()) {
Column {
SectionHeader("TAN Medien")
Column(Modifier.padding(top = 8.dp)) {
bank.tanMediaSorted.forEach { tanMedium ->
SelectableFormListItem(tanMedium.displayName, tanMedium == bank.selectedTanMedium, "TAN Medium ist ausgewähltes TAN Medium")
}
}
}
}
Spacer(Modifier.weight(1f))
Column(Modifier.padding(top = 24.dp, bottom = 18.dp)) {
TextButton(modifier = Modifier.fillMaxWidth().height(50.dp), onClick = { showDeleteBankAccessConfirmationDialog = true }) {
Text("Konto löschen", fontSize = 15.sp, color = Colors.DestructiveColor, textAlign = TextAlign.Center)
}
}
}
}
}

View File

@ -0,0 +1,186 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import net.codinux.banking.persistence.entities.BankAccountEntity
import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.composables.tan.ImageView
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.ImeNext
import net.codinux.banking.ui.extensions.rememberVerticalScroll
import net.codinux.banking.ui.forms.CaptionText
import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.Config.NewLine
import net.codinux.log.Log
private val epcQrCodeService = DI.epcQrCodeService
@Composable
fun CreateEpcQrCodeScreen(onClosed: () -> Unit) {
val banks = DI.uiState.banks.collectAsState().value
val accountsWithIban: List<BankAccountEntity?> = buildList {
add(null)
addAll(DI.uiState.accounts.collectAsState().value.filter { it.iban != null })
}
var selectedAccount by remember { mutableStateOf<BankAccountEntity?>(null) }
val bankOfSelectedAccount by remember(selectedAccount) {
derivedStateOf { banks.firstOrNull { it.id == selectedAccount?.bankId } }
}
val amountFocus = remember { FocusRequester() }
var receiverName by remember { mutableStateOf("") }
var iban by remember { mutableStateOf("") }
var bic by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var reference by remember { mutableStateOf("") }
var informationForUser by remember { mutableStateOf("") }
var epcQrCodeGeneratingError by remember { mutableStateOf<String?>(null) }
val epcQrCodeBytes by remember(receiverName, iban, bic, amount, reference, informationForUser) {
derivedStateOf {
epcQrCodeGeneratingError = null
if (receiverName.isNotBlank() && iban.isNotBlank()) {
try {
epcQrCodeService.generateEpcQrCode(receiverName, iban, bic.takeUnless { it.isBlank() }, amount.takeUnless { it.isBlank() }, reference.takeUnless { it.isBlank() }, informationForUser.takeUnless { it.isBlank() })
} catch (e: Throwable) {
Log.error(e) { "Could not generate EPC QR Code" }
epcQrCodeGeneratingError = e.message
null
}
} else {
null
}
}
}
FullscreenViewBase("EPC QR Code erstellen", "Schließen", onClosed = onClosed) {
Column(Modifier.fillMaxWidth().rememberVerticalScroll()) {
if (epcQrCodeGeneratingError != null) {
Text("QR Code konnte nicht erstellt werden:${NewLine}$epcQrCodeGeneratingError", color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
} else if (epcQrCodeBytes == null) {
Text("Mit EPC QR Codes, welche als GiroCode, scan2code, ... vermarktet werden, können Überweisungsdaten ganz einfach von Banking Apps eingelesen werden.")
Text("Hier können Sie Ihren eigenen erstellen, so dass jemand Ihre Überweisungsdaten einlesen und Ihnen ganz schnell Geld überweisen kann.")
} else {
ImageView(epcQrCodeBytes!!, "EpcQrCode", "Erzeugter EPC QR Code", 350, 100, 700)
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.Center) {
Text("Scannen Sie diesen Code auf einem anderen Gerät mit einer Banking App, z. B. Bankmeister", textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp))
}
}
if (accountsWithIban.size > 1) {
Select(
"Für Konto",
accountsWithIban,
selectedAccount,
{ account ->
selectedAccount = account
if (account != null) {
iban = account.iban ?: ""
bic = banks.firstOrNull { it.id == selectedAccount?.bankId }?.bic ?: ""
receiverName = account.accountHolderName
amountFocus.requestFocus()
}
},
{ account -> account?.displayName ?: "" },
leadingIcon = bankOfSelectedAccount?.let { { BankIcon(bankOfSelectedAccount) } },
dropDownItemContent = { account ->
Row(verticalAlignment = Alignment.CenterVertically) {
BankIcon(banks.firstOrNull { it.id == account?.bankId }, Modifier.padding(end = 6.dp))
Text(account?.displayName ?: "")
}
},
modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)
)
}
OutlinedTextField(
label = { Text("Empfänger*in") },
value = receiverName,
onValueChange = { receiverName = it },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
keyboardOptions = KeyboardOptions.ImeNext
)
CaptionText("${receiverName.length} / 70")
OutlinedTextField(
label = { Text("IBAN") },
value = iban,
onValueChange = { iban = it },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
keyboardOptions = KeyboardOptions.ImeNext
)
OutlinedTextField(
label = { Text("BIC (optional)") },
value = bic,
onValueChange = { bic = it },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
keyboardOptions = KeyboardOptions.ImeNext
)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text("Betrag (optional)") },
value = amount,
onValueChange = { amount = it },
modifier = Modifier.weight(1f).padding(vertical = 8.dp).focusRequester(amountFocus),
keyboardOptions = KeyboardOptions.ImeNext
)
Text(DI.formatUtil.formatCurrency("EUR"), Modifier.padding(start = 4.dp)) // Euro is currently the only supported currency
}
OutlinedTextField(
label = { Text("Verwendungszweck (optional)") },
value = reference,
onValueChange = { reference = it },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
keyboardOptions = KeyboardOptions.ImeNext
)
CaptionText("${reference.length} / 140")
// not exposing it to user as it's a) not displayed by most apps and b) may causes overflow of used QR code library
// OutlinedTextField(
// label = { Text("Information für den Nutzer (optional)") },
// value = informationForUser,
// onValueChange = { informationForUser = it },
// modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
// keyboardOptions = KeyboardOptions.ImeNext
// )
//
// CaptionText("${informationForUser.length} / 70")
}
}
}

View File

@ -1,6 +1,5 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
@ -14,12 +13,18 @@ import androidx.compose.ui.text.style.TextAlign
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.persistence.entities.AccountTransactionEntity
import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.PlatformType
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.horizontalScroll
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.service.BankDataImporterAndExporter
private const val iOSMaxDisplayedDataLength = 20_000
@Composable
fun ExportScreen(onClosed: () -> Unit) {
var transactions: Collection<AccountTransactionEntity>
@ -28,6 +33,8 @@ fun ExportScreen(onClosed: () -> Unit) {
var exportedDataText by remember { mutableStateOf("") }
var exportedDataTextToDisplay by remember { mutableStateOf("") }
val importerExporter = BankDataImporterAndExporter()
val clipboardManager = LocalClipboardManager.current
@ -41,12 +48,15 @@ fun ExportScreen(onClosed: () -> Unit) {
withContext(Dispatchers.Main) {
exportedDataText = initiallyExportedData
exportedDataTextToDisplay = if (DI.platform.type == PlatformType.iOS && exportedDataText.length > iOSMaxDisplayedDataLength) exportedDataText.substring(0,
iOSMaxDisplayedDataLength) + "\r\n...\r\n(Wir mussten die Anzeige abschneiden, da iOS mit längeren Zeichenketten nicht klar kommt (iOS only bug)"
else exportedDataText
isLoadingExportedData = false
}
}
FullscreenViewBase("Umsätze exportieren", onClosed = onClosed) {
Column {
Column(Modifier.fillMaxWidth()) {
Text("Es gibt leider noch keinen \"Datei auswählen Dialog\", ist sehr schwierig plattformübergreifend umzusetzen, deshalb bitte folgenden Text kopieren und in eine Textdatei einfügen:")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
@ -55,13 +65,7 @@ fun ExportScreen(onClosed: () -> Unit) {
}
}
if (isLoadingExportedData == false) {
Column(Modifier.verticalScroll(ScrollState(0), enabled = true).horizontalScroll(ScrollState(0), enabled = true)) {
SelectionContainer {
Text(exportedDataText, fontFamily = FontFamily.Monospace)
}
}
} else {
if (isLoadingExportedData) {
Column(Modifier.fillMaxSize()) {
Spacer(Modifier.weight(1f))
@ -71,6 +75,12 @@ fun ExportScreen(onClosed: () -> Unit) {
Spacer(Modifier.weight(1f))
}
} else {
Column(Modifier.verticalScroll().horizontalScroll()) {
SelectionContainer(modifier = Modifier.fillMaxSize()) {
Text(exportedDataTextToDisplay, fontFamily = FontFamily.Monospace)
}
}
}
}
}

View File

@ -0,0 +1,103 @@
package net.codinux.banking.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.persistence.entities.BankAccountEntity
import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.extensions.horizontalScroll
import net.codinux.banking.ui.extensions.verticalScroll
import net.codinux.banking.ui.forms.SectionHeader
import net.codinux.banking.ui.forms.Select
import net.codinux.banking.ui.model.Config.NewLine
@Composable
fun FeedbackScreen(onClosed: () -> Unit) {
val banks = DI.uiState.banks.collectAsState().value
val messageLog by remember { mutableStateOf(DI.bankingService.getMessageLog()) }
val accountsWithMessageLog: List<BankAccount?> by remember(messageLog) { derivedStateOf {
listOf<BankAccount?>(null) + messageLog.mapNotNull { it.account }.toSet().toList()
} }
var selectedAccount by remember { mutableStateOf<BankAccount?>(null) }
val bankOfSelectedAccount by remember(selectedAccount) {
// TODO: MessageLogEntries of added accounts contain a BankAccount instead of a BankAccountEntity object
derivedStateOf { banks.firstOrNull { it.id == (selectedAccount as? BankAccountEntity)?.bankId } }
}
val displayedLogEntries by remember(messageLog, selectedAccount) { derivedStateOf {
messageLog.filter { selectedAccount == null || it.account == selectedAccount }.sortedBy { it.messageNumber }
} }
val displayedLogEntriesText by remember(displayedLogEntries) {
derivedStateOf { displayedLogEntries.map {
"${it.messageNumberString} ${it.bank?.displayName ?: ""} ${it.account?.displayName ?: ""} ${it.type} ${it.jobType} ${it.messageCategory}${NewLine}${it.message}"
}.joinToString(NewLine + NewLine) }
}
val clipboardManager = LocalClipboardManager.current
FullscreenViewBase("Feedback", onClosed = onClosed) {
Column(Modifier.fillMaxWidth()) {
Text("Man kann uns direkt aus der App heraus noch kein Feedback schicken (kommt noch), aber schon mal das Nachrichten-Protokoll, das ihr bei Fehlern an die (unfähgigen) Entwickler schicken könnt:", modifier = Modifier.padding(horizontal = 24.dp).padding(top = 8.dp), textAlign = TextAlign.Center)
SectionHeader("Nachrichten-Protokoll:") // TODO: add a Switch "Add message protocol"
if (messageLog.isEmpty()) {
Row(Modifier.fillMaxSize().padding(24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Text("Sie haben noch keine Aktion wie das Abrufen der Kontoumsätze ausgeführt, deshalb gibt es noch kein Nachrichtenprotokoll zu diesen Aktionen", textAlign = TextAlign.Center)
}
} else {
Row(Modifier.fillMaxWidth().padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Select(
"Konto",
accountsWithMessageLog,
selectedAccount,
{ account -> selectedAccount = account },
{ account -> account?.displayName ?: "Alle Konten" },
Modifier.weight(0.9f),
leadingIcon = bankOfSelectedAccount?.let { { BankIcon(bankOfSelectedAccount) } },
dropDownItemContent = { account ->
Row(verticalAlignment = Alignment.CenterVertically) {
BankIcon(banks.firstOrNull { it.id == (account as? BankAccountEntity)?.bankId }, Modifier.padding(end = 6.dp))
Text(account?.displayName ?: "")
}
},
)
Spacer(Modifier.weight(0.1f))
TextButton({ clipboardManager.setText(AnnotatedString(displayedLogEntriesText))}) {
Text("Kopieren", color = Colors.CodinuxSecondaryColor)
}
}
Column(Modifier.verticalScroll().horizontalScroll().padding(top = 8.dp)) {
SelectionContainer(modifier = Modifier.fillMaxSize()) {
Text(displayedLogEntriesText, fontFamily = FontFamily.Monospace)
}
}
}
}
}
}

View File

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

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