Showing bank's favicon (if available)

This commit is contained in:
dankito 2024-08-28 17:48:50 +02:00
parent 4856ced158
commit d9a3e942e9
19 changed files with 285 additions and 35 deletions

View File

@ -1,6 +1,11 @@
package net.codinux.banking.ui package net.codinux.banking.ui
import android.os.Build import android.os.Build
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IOorDefault: CoroutineDispatcher
get() = Dispatchers.IO
class AndroidPlatform : Platform { class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}" override val name: String = "Android ${Build.VERSION.SDK_INT}"

View File

@ -3,6 +3,10 @@ package net.codinux.banking.ui.service
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import java.net.URL
actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap = actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap =
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap() BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap()
actual fun fetchBytesFromUrl(url: String): ByteArray =
URL(url).openStream().buffered().use { it.readBytes() }

View File

@ -1,5 +1,10 @@
package net.codinux.banking.ui package net.codinux.banking.ui
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
expect val Dispatchers.IOorDefault: CoroutineDispatcher
expect fun getPlatform(): Platform expect fun getPlatform(): Platform

View File

@ -0,0 +1,50 @@
package net.codinux.banking.ui.composables
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.client.model.UserAccountViewInfo
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.BankInfo
import net.dankito.banking.banklistcreator.prettifier.BankingGroupMapper
private val bankIconService = DI.bankIconService
@Composable
fun BankIcon(userAccount: UserAccount?, modifier: Modifier = Modifier) {
val iconUrl by remember(userAccount?.bic) { mutableStateOf(userAccount?.let { bankIconService.findIconForBank(it) }) }
BankIcon(iconUrl, modifier)
}
private val bankingGroupMapper = BankingGroupMapper()
@Composable
fun BankIcon(bank: BankInfo, modifier: Modifier = Modifier) {
val iconUrl by remember(bank.bic) { mutableStateOf(bankIconService.findIconForBank(bank.name, bankingGroupMapper.getBankingGroup(bank.name, bank.bic))) }
BankIcon(iconUrl, modifier)
}
@Composable
fun BankIcon(userAccount: UserAccountViewInfo?, modifier: Modifier = Modifier) {
val iconUrl = userAccount?.let { bankIconService.findIconForBank(it.bankName, it.bankingGroup) }
BankIcon(iconUrl, modifier)
}
@Composable
fun BankIcon(iconUrl: String?, modifier: Modifier = Modifier) {
Column(modifier) {
iconUrl?.let {
IconForUrl(iconUrl, "Favicon of this bank", Modifier.width(16.dp).height(16.dp))
}
}
}

View File

@ -0,0 +1,35 @@
package net.codinux.banking.ui.composables
import androidx.compose.foundation.Image
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.config.DI
private val imageCache = DI.imageCache
@Composable
fun IconForUrl(iconUrl: String, contentDescription: String, modifier: Modifier = Modifier) {
var imageBitmap by remember(iconUrl) { mutableStateOf<ImageBitmap?>(null) }
val coroutineScope = rememberCoroutineScope()
if (iconUrl.endsWith(".svg") == false) { // SVG is not supported on Android
coroutineScope.launch(Dispatchers.IOorDefault) {
val received = imageCache.getImageBitmap(iconUrl)
withContext(Dispatchers.Main) {
imageBitmap = received
}
}
}
imageBitmap?.let { imageBitmap ->
Image(imageBitmap, contentDescription, modifier)
}
}

View File

@ -9,25 +9,30 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.model.AccountTransactionViewModel import net.codinux.banking.ui.model.AccountTransactionViewModel
private val formatUtil = DI.formatUtil private val formatUtil = DI.formatUtil
@Composable @Composable
fun TransactionListItem(transaction: AccountTransactionViewModel, backgroundColor: Color) { fun TransactionListItem(userAccount: UserAccount?, transaction: AccountTransactionViewModel, backgroundColor: Color) {
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.background(color = backgroundColor) .background(color = backgroundColor)
.padding(horizontal = 6.dp, vertical = 6.dp) .padding(horizontal = 6.dp, vertical = 6.dp)
) { ) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Row {
text = transaction.otherPartyName ?: "", BankIcon(userAccount, Modifier.padding(end = 6.dp))
Modifier.fillMaxWidth(),
maxLines = 1, Text(
overflow = TextOverflow.Ellipsis text = transaction.otherPartyName ?: "",
) Modifier.fillMaxWidth(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))

View File

@ -15,7 +15,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount import net.codinux.banking.client.model.Amount
import net.codinux.banking.ui.extensions.toBigDecimal import net.codinux.banking.ui.extensions.toBigDecimal
import net.codinux.banking.ui.forms.RoundedCornersCard import net.codinux.banking.ui.forms.RoundedCornersCard
@ -28,6 +27,11 @@ private val formatUtil = DI.formatUtil
@Composable @Composable
fun TransactionsList(uiState: UiState) { fun TransactionsList(uiState: UiState) {
val users by uiState.userAccounts.collectAsState()
val usersById by remember(users) {
derivedStateOf { users.associateBy { it.id } }
}
val transactions by uiState.transactions.collectAsState() val transactions by uiState.transactions.collectAsState()
val groupedByMonth by remember(transactions) { val groupedByMonth by remember(transactions) {
@ -53,7 +57,8 @@ fun TransactionsList(uiState: UiState) {
RoundedCornersCard { RoundedCornersCard {
Column(Modifier.background(Color.White)) { // LazyColumn inside LazyColumn is not allowed Column(Modifier.background(Color.White)) { // LazyColumn inside LazyColumn is not allowed
monthTransactions.forEachIndexed { index, transaction -> monthTransactions.forEachIndexed { index, transaction ->
TransactionListItem(transaction, if (index % 2 == 1) Colors.Zinc100_50 else Color.Transparent) val backgroundColor = if (index % 2 == 1) Colors.Zinc100_50 else Color.Transparent
TransactionListItem(usersById[transaction.userAccountId], transaction, backgroundColor)
if (index < monthTransactions.size - 1) { if (index < monthTransactions.size - 1) {
Divider(color = Colors.Zinc200, thickness = 1.dp) Divider(color = Colors.Zinc200, thickness = 1.dp)

View File

@ -18,8 +18,11 @@ object DI {
val formatUtil = FormatUtil() val formatUtil = FormatUtil()
val imageCache = ImageCache()
val bankFinder = BankFinder() val bankFinder = BankFinder()
val bankIconService = BankIconService()
var bankingRepository: BankingRepository = InMemoryBankingRepository(emptyList()) var bankingRepository: BankingRepository = InMemoryBankingRepository(emptyList())

View File

@ -1,24 +1,20 @@
package net.codinux.banking.ui.dialogs package net.codinux.banking.ui.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.codinux.banking.ui.forms.* import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.model.BankInfo
import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.Colors
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.config.Style import net.codinux.banking.ui.forms.*
import net.codinux.banking.ui.model.BankInfo
private val bankingService = DI.bankingService private val bankingService = DI.bankingService
@ -72,7 +68,16 @@ fun AddAccountDialog(
getItemTitle = { bank -> bank.name }, getItemTitle = { bank -> bank.name },
fetchSuggestions = { query -> bankingService.findBanks(query) } fetchSuggestions = { query -> bankingService.findBanks(query) }
) { bank -> ) { bank ->
Text(bank.name) Row {
BankIcon(bank, Modifier.padding(end = 6.dp))
Text(
bank.name,
Modifier.fillMaxWidth(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) { Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text(bank.bankCode) Text(bank.bankCode)

View File

@ -20,7 +20,7 @@ import bankmeister.composeapp.generated.resources.zoom_in
import bankmeister.composeapp.generated.resources.zoom_out import bankmeister.composeapp.generated.resources.zoom_out
import net.codinux.banking.client.model.tan.AllowedTanFormat import net.codinux.banking.client.model.tan.AllowedTanFormat
import net.codinux.banking.client.model.tan.EnterTanResult import net.codinux.banking.client.model.tan.EnterTanResult
import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.composables.BankIcon
import net.codinux.banking.ui.config.Internationalization import net.codinux.banking.ui.config.Internationalization
import net.codinux.banking.ui.forms.OutlinedTextField import net.codinux.banking.ui.forms.OutlinedTextField
import net.codinux.banking.ui.model.TanChallengeReceived import net.codinux.banking.ui.model.TanChallengeReceived
@ -63,6 +63,8 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Row { Row {
BankIcon(challenge.user, Modifier.padding(end = 6.dp))
Text("${challenge.user.bankName}, Nutzer ${challenge.user.loginName}${challenge.account?.let { ", Konto ${it.productName ?: it.identifier}" } ?: ""}") Text("${challenge.user.bankName}, Nutzer ${challenge.user.loginName}${challenge.account?.let { ", Konto ${it.productName ?: it.identifier}" } ?: ""}")
} }
Text( Text(

View File

@ -0,0 +1,39 @@
package net.codinux.banking.ui.service
import net.codinux.banking.client.model.BankingGroup
import net.codinux.banking.client.model.UserAccount
class BankIconService { // TODO: extract to a common library
fun findIconForBank(user: UserAccount) = findIconForBank(user.bankName, user.bankingGroup)
fun findIconForBank(bankName: String, bankingGroup: BankingGroup? = null): String? = when (bankingGroup) {
BankingGroup.Sparkasse -> "https://sparkasse.de/favicon-32x32.png"
BankingGroup.DKB -> "https://www.ib.dkb.de/favicon.ico"
BankingGroup.OldenburgischeLandesbank -> "https://olb.de/assets/img/icon/olb/favicon-32x32.png"
BankingGroup.VolksUndRaiffeisenbanken -> "https://vr.de/favicon.ico"
BankingGroup.Sparda -> "https://www.sparda.de/hidden/layout/images/touchicons/favicon-32x32.png"
BankingGroup.PSD -> "https://www.psd-muenchen.de/favicon.ico"
BankingGroup.GLS -> "https://gls.de/assets/dist/img/icons/v2/favicon-32x32.png" // "https://gls.de/favicon.ico"
BankingGroup.DeutscheBank -> "https://www.deutsche-bank.de/etc/designs/db-eccs-pws-pwcc/assets/db-favicon-167x167.png" // "https://www.deutsche-bank.de/etc/designs/db-eccs-pws-pwcc/assets/favicon.svg" // https://master.dwebcms.db.com/application/themes/default/favicon/favicon-32x32.png
BankingGroup.Postbank -> "https://postbank.de/etc/designs/pb-eccs-pb/icons/pb-favicon-167x167.png" // "https://postbank.de/etc/designs/pb-eccs-pb/icons/favicon.svg"
BankingGroup.Commerzbank -> "https://commerzbank.de/ms/media/favicons/apple-touch-icon-57x57_A.png"
BankingGroup.Comdirect -> "https://comdirect.de/favicon.ico"
BankingGroup.Unicredit -> "https://hypovereinsbank.de/etc/designs/hypovereinsbank/img/favicon/favicon.ico"
BankingGroup.Targobank -> "https://targobank.de/de/images/favicon/favicon.png" // https://targobank.de/favicon.ico
BankingGroup.ING -> "https://ing.de/favicon.ico" // "https://cdn.ing.de/ing-cms-ui/129.0.2/assets/favicons/apple-touch-icon.png"
BankingGroup.Santander -> "https://www.santander.de/ressourcen/img/favicons/favicon.ico"
BankingGroup.Norisbank -> "https://norisbank.de/etc/designs/db-eccs-nb/assets/nb-favicon-167x167.png" // ""https://norisbank.de/etc/designs/db-eccs-nb/assets/favicon.svg"
BankingGroup.Degussa -> "https://www.degussa-bank.de/o/degussa-bank-theme/images/favicon.ico"
BankingGroup.N26 -> "https://n26.de/favicon.ico"
else -> null // TODO: call Favicon web service
}
}

View File

@ -0,0 +1,69 @@
package net.codinux.banking.ui.service
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.codinux.log.logger
// TODO: save retrieved bytes to disk so that they don't have to be retrieved on the next app start again
class ImageCache {
private val mutex = Mutex()
private val imageBytesCache = mutableMapOf<String, Deferred<ByteArray?>>()
private val imageBitmapCache = mutableMapOf<String, Deferred<ImageBitmap?>>()
private val log by logger()
// would be create but didn't get it to work with Ktor. Had therefor to introduce platform specific URL loading with loadImageBytes()
// simply re-using KtorWebClient as it already has all platform specific engines set up
// private val webClient = object : KtorWebClient() {
//
// val ktorClient = client
// init {
// client
// }
// }
suspend fun getImageBytes(url: String): ByteArray? = coroutineScope {
mutex.withLock {
var cached = imageBytesCache[url]
if (cached == null || cached.isCancelled) {
//LAZY - to free the mutex lock as fast as possible
val loadJob = async(start = CoroutineStart.LAZY) {
loadImage(url) // TODO: in case of failure, remove url from cache and try again?
}
imageBytesCache[url] = loadJob
loadJob
} else {
cached
}
}.await()
}
suspend fun getImageBitmap(url: String): ImageBitmap? = coroutineScope {
getImageBytes(url)?.let {
createImageBitmap(it)
}
}
private suspend fun loadImage(url: String): ByteArray? {
return try {
// val clientResponse = webClient.ktorClient.get(url)
// if (clientResponse.status.isSuccess()) {
// clientResponse.readBytes()
// } else {
// null
// }
fetchBytesFromUrl(url)
} catch (e: Throwable) {
log.error(e) { "Failed to load image from url '$url'" }
null
}
}
}

View File

@ -3,3 +3,5 @@ package net.codinux.banking.ui.service
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
expect fun createImageBitmap(imageBytes: ByteArray): ImageBitmap expect fun createImageBitmap(imageBytes: ByteArray): ImageBitmap
expect fun fetchBytesFromUrl(url: String): ByteArray

View File

@ -0,0 +1,16 @@
package net.codinux.banking.ui
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IOorDefault: CoroutineDispatcher
get() = Dispatchers.IO
class JVMPlatform: Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
override val type: PlatformType = PlatformType.JVM
}
actual fun getPlatform(): Platform = JVMPlatform()

View File

@ -1,9 +0,0 @@
package net.codinux.banking.ui
class JVMPlatform: Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
override val type: PlatformType = PlatformType.JVM
}
actual fun getPlatform(): Platform = JVMPlatform()

View File

@ -2,6 +2,7 @@ package net.codinux.banking.ui.dialogs
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import net.codinux.banking.client.model.BankingGroup
import net.codinux.banking.client.model.UserAccountViewInfo import net.codinux.banking.client.model.UserAccountViewInfo
import net.codinux.banking.client.model.tan.* import net.codinux.banking.client.model.tan.*
import net.codinux.banking.ui.model.TanChallengeReceived import net.codinux.banking.ui.model.TanChallengeReceived
@ -10,7 +11,7 @@ import net.codinux.banking.ui.model.TanChallengeReceived
@Composable @Composable
fun EnterTanDialogPreview_EnterTan() { fun EnterTanDialogPreview_EnterTan() {
val tanMethods = listOf(TanMethod("Zeig mich an", TanMethodType.AppTan, "902")) val tanMethods = listOf(TanMethod("Zeig mich an", TanMethodType.AppTan, "902"))
val user = UserAccountViewInfo("12345678", "SupiDupiNutzer", "Abzockbank") val user = UserAccountViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank)
val tanChallenge = TanChallenge(TanChallengeType.EnterTan, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, user = user) val tanChallenge = TanChallenge(TanChallengeType.EnterTan, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, user = user)
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { } EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
@ -24,7 +25,7 @@ fun EnterTanDialogPreview_TanImage() {
val tanMethod = TanMethod("photoTAN-Verfahren", TanMethodType.photoTan, "902", 6, AllowedTanFormat.Numeric) val tanMethod = TanMethod("photoTAN-Verfahren", TanMethodType.photoTan, "902", 6, AllowedTanFormat.Numeric)
val tanImage = TanImage("image/png", tanImageBytes) val tanImage = TanImage("image/png", tanImageBytes)
val user = UserAccountViewInfo("10010010", "Ihr krasser Login Name", "Phantasie Bank") val user = UserAccountViewInfo("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, user) val tanChallenge = TanChallenge(TanChallengeType.Image, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethod.identifier, listOf(tanMethod), null, emptyList(), tanImage, null, user)

View File

@ -3,6 +3,10 @@ package net.codinux.banking.ui.service
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.net.URL
actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap = actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap =
Image.makeFromEncoded(imageBytes).toComposeImageBitmap() Image.makeFromEncoded(imageBytes).toComposeImageBitmap()
actual fun fetchBytesFromUrl(url: String): ByteArray =
URL(url).openStream().buffered().use { it.readAllBytes() }

View File

@ -1,5 +1,12 @@
package net.codinux.banking.ui package net.codinux.banking.ui
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IOorDefault: CoroutineDispatcher
get() = Dispatchers.Default
class JsPlatform: Platform { class JsPlatform: Platform {
override val name: String = "Web with Kotlin/Js" override val name: String = "Web with Kotlin/Js"

View File

@ -6,3 +6,5 @@ import org.jetbrains.skia.Image
actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap = actual fun createImageBitmap(imageBytes: ByteArray): ImageBitmap =
Image.makeFromEncoded(imageBytes).toComposeImageBitmap() Image.makeFromEncoded(imageBytes).toComposeImageBitmap()
actual fun fetchBytesFromUrl(url: String): ByteArray = ByteArray(0)