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
import android.os.Build
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IOorDefault: CoroutineDispatcher
get() = Dispatchers.IO
class AndroidPlatform : Platform {
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 androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.net.URL
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
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
expect val Dispatchers.IOorDefault: CoroutineDispatcher
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.text.style.TextOverflow
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.model.AccountTransactionViewModel
private val formatUtil = DI.formatUtil
@Composable
fun TransactionListItem(transaction: AccountTransactionViewModel, backgroundColor: Color) {
fun TransactionListItem(userAccount: UserAccount?, transaction: AccountTransactionViewModel, backgroundColor: Color) {
Row(
modifier = Modifier.fillMaxWidth()
.background(color = backgroundColor)
.padding(horizontal = 6.dp, vertical = 6.dp)
) {
Column(Modifier.weight(1f)) {
Text(
text = transaction.otherPartyName ?: "",
Modifier.fillMaxWidth(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row {
BankIcon(userAccount, Modifier.padding(end = 6.dp))
Text(
text = transaction.otherPartyName ?: "",
Modifier.fillMaxWidth(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
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.sp
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.ui.extensions.toBigDecimal
import net.codinux.banking.ui.forms.RoundedCornersCard
@ -28,6 +27,11 @@ private val formatUtil = DI.formatUtil
@Composable
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 groupedByMonth by remember(transactions) {
@ -53,7 +57,8 @@ fun TransactionsList(uiState: UiState) {
RoundedCornersCard {
Column(Modifier.background(Color.White)) { // LazyColumn inside LazyColumn is not allowed
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) {
Divider(color = Colors.Zinc200, thickness = 1.dp)

View File

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

View File

@ -1,24 +1,20 @@
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.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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.codinux.banking.ui.forms.*
import net.codinux.banking.ui.model.BankInfo
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.config.Style
import net.codinux.banking.ui.forms.*
import net.codinux.banking.ui.model.BankInfo
private val bankingService = DI.bankingService
@ -72,7 +68,16 @@ fun AddAccountDialog(
getItemTitle = { bank -> bank.name },
fetchSuggestions = { query -> bankingService.findBanks(query) }
) { 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)) {
Text(bank.bankCode)

View File

@ -20,7 +20,7 @@ import bankmeister.composeapp.generated.resources.zoom_in
import bankmeister.composeapp.generated.resources.zoom_out
import net.codinux.banking.client.model.tan.AllowedTanFormat
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.forms.OutlinedTextField
import net.codinux.banking.ui.model.TanChallengeReceived
@ -63,6 +63,8 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () ->
Column(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth()) {
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(

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

@ -2,4 +2,6 @@ package net.codinux.banking.ui.service
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.runtime.Composable
import net.codinux.banking.client.model.BankingGroup
import net.codinux.banking.client.model.UserAccountViewInfo
import net.codinux.banking.client.model.tan.*
import net.codinux.banking.ui.model.TanChallengeReceived
@ -10,7 +11,7 @@ import net.codinux.banking.ui.model.TanChallengeReceived
@Composable
fun EnterTanDialogPreview_EnterTan() {
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)
EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { }
@ -24,7 +25,7 @@ fun EnterTanDialogPreview_TanImage() {
val tanMethod = TanMethod("photoTAN-Verfahren", TanMethodType.photoTan, "902", 6, AllowedTanFormat.Numeric)
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)

View File

@ -3,6 +3,10 @@ package net.codinux.banking.ui.service
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
import java.net.URL
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
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val Dispatchers.IOorDefault: CoroutineDispatcher
get() = Dispatchers.Default
class JsPlatform: Platform {
override val name: String = "Web with Kotlin/Js"

View File

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