forked from shihaam/thijooree
fix scroll bug
This commit is contained in:
@@ -3,6 +3,7 @@ package sh.sar.basedbank
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
@@ -19,6 +20,9 @@ class BasedBankApp : Application() {
|
|||||||
var bmlSession: BmlSession? = null
|
var bmlSession: BmlSession? = null
|
||||||
var bmlAccounts: List<MibAccount> = emptyList()
|
var bmlAccounts: List<MibAccount> = emptyList()
|
||||||
|
|
||||||
|
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||||
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
val mibLoginFlow by lazy {
|
val mibLoginFlow by lazy {
|
||||||
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
|
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,8 +180,9 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
.add("data", encrypted)
|
.add("data", encrypted)
|
||||||
.build()
|
.build()
|
||||||
val response = post(formBody)
|
val response = post(formBody)
|
||||||
val result = MibCrypto.decrypt(response, session.sessionKey)
|
// Server returns plain JSON (not encrypted) for error responses (e.g. expired session)
|
||||||
return result
|
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
||||||
|
return MibCrypto.decrypt(response, session.sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
|
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -27,6 +29,17 @@ class AccountHistoryAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
|
private var lastInsertedDateKey = ""
|
||||||
|
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
|
|
||||||
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
|
imageCache[counterpartyName] = bitmap
|
||||||
|
displayItems.forEachIndexed { i, item ->
|
||||||
|
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
||||||
|
notifyItemChanged(i + 1) // +1 for account header at position 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var _showLoadingFooter = false
|
private var _showLoadingFooter = false
|
||||||
var showLoadingFooter: Boolean
|
var showLoadingFooter: Boolean
|
||||||
@@ -43,13 +56,14 @@ class AccountHistoryAdapter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the given (already sorted + filtered) list with date group headers.
|
* Display the given (already sorted + filtered) list with date group headers.
|
||||||
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
||||||
*/
|
*/
|
||||||
fun setTransactions(transactions: List<Transaction>) {
|
fun setTransactions(transactions: List<Transaction>) {
|
||||||
_showLoadingFooter = false
|
_showLoadingFooter = false
|
||||||
displayItems.clear()
|
displayItems.clear()
|
||||||
|
lastInsertedDateKey = ""
|
||||||
var lastDateKey = ""
|
var lastDateKey = ""
|
||||||
for (trx in transactions) {
|
for (trx in transactions) {
|
||||||
val dateKey = trx.date.take(10)
|
val dateKey = trx.date.take(10)
|
||||||
@@ -59,9 +73,34 @@ class AccountHistoryAdapter(
|
|||||||
}
|
}
|
||||||
displayItems.add(Item.Trx(trx))
|
displayItems.add(Item.Trx(trx))
|
||||||
}
|
}
|
||||||
|
lastInsertedDateKey = lastDateKey
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
||||||
|
* notifications, so the RecyclerView doesn't reset scroll position.
|
||||||
|
*/
|
||||||
|
fun appendTransactions(newTransactions: List<Transaction>) {
|
||||||
|
if (newTransactions.isEmpty()) return
|
||||||
|
if (_showLoadingFooter) {
|
||||||
|
val pos = itemCount - 1
|
||||||
|
_showLoadingFooter = false
|
||||||
|
notifyItemRemoved(pos)
|
||||||
|
}
|
||||||
|
val oldCount = displayItems.size
|
||||||
|
for (trx in newTransactions) {
|
||||||
|
val dateKey = trx.date.take(10)
|
||||||
|
if (dateKey != lastInsertedDateKey) {
|
||||||
|
displayItems.add(Item.DateHeader(formatDateHeader(trx.date)))
|
||||||
|
lastInsertedDateKey = dateKey
|
||||||
|
}
|
||||||
|
displayItems.add(Item.Trx(trx))
|
||||||
|
}
|
||||||
|
val added = displayItems.size - oldCount
|
||||||
|
if (added > 0) notifyItemRangeInserted(1 + oldCount, added) // +1 for account header
|
||||||
|
}
|
||||||
|
|
||||||
// Position 0 = account header card
|
// Position 0 = account header card
|
||||||
// Positions 1..displayItems.size = date headers + transactions
|
// Positions 1..displayItems.size = date headers + transactions
|
||||||
// Last position = loading footer when showLoadingFooter = true
|
// Last position = loading footer when showLoadingFooter = true
|
||||||
@@ -136,15 +175,25 @@ class AccountHistoryAdapter(
|
|||||||
fun bind(trx: Transaction) {
|
fun bind(trx: Transaction) {
|
||||||
val isCredit = trx.amount >= 0
|
val isCredit = trx.amount >= 0
|
||||||
val color = sourceColor(trx.source)
|
val color = sourceColor(trx.source)
|
||||||
val initial = (trx.counterpartyName ?: trx.description)
|
val name = trx.counterpartyName ?: trx.description
|
||||||
.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
val circle = GradientDrawable().apply {
|
val bitmap = trx.counterpartyName?.let { imageCache[it] }
|
||||||
shape = GradientDrawable.OVAL
|
if (bitmap != null) {
|
||||||
setColor(Color.parseColor(color))
|
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
|
||||||
|
rd.isCircular = true
|
||||||
|
b.fvAvatar.background = rd
|
||||||
|
b.tvInitial.visibility = View.INVISIBLE
|
||||||
|
} else {
|
||||||
|
val circle = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(Color.parseColor(color))
|
||||||
|
}
|
||||||
|
b.fvAvatar.background = circle
|
||||||
|
b.tvInitial.visibility = View.VISIBLE
|
||||||
|
b.tvInitial.text = initial
|
||||||
|
if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
|
||||||
}
|
}
|
||||||
b.fvAvatar.background = circle
|
|
||||||
b.tvInitial.text = initial
|
|
||||||
b.tvDescription.text = trx.description
|
b.tvDescription.text = trx.description
|
||||||
|
|
||||||
val counterparty = trx.counterpartyName
|
val counterparty = trx.counterpartyName
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -13,14 +15,17 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
import sh.sar.basedbank.api.mib.Transaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -37,6 +42,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
private val allTransactions = mutableListOf<Transaction>()
|
private val allTransactions = mutableListOf<Transaction>()
|
||||||
private var searchQuery = ""
|
private var searchQuery = ""
|
||||||
private var firstPageDone = false
|
private var firstPageDone = false
|
||||||
|
private val pendingImageNames = mutableSetOf<String>()
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
private var mibNextStart = 1
|
private var mibNextStart = 1
|
||||||
@@ -67,6 +73,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
||||||
|
|
||||||
adapter = AccountHistoryAdapter(account)
|
adapter = AccountHistoryAdapter(account)
|
||||||
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
@@ -74,7 +81,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
if (dy <= 0 || isLoading) return
|
if (dy <= 0 || isLoading) return
|
||||||
val lm = rv.layoutManager as LinearLayoutManager
|
val lm = rv.layoutManager as LinearLayoutManager
|
||||||
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 3) {
|
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 3) {
|
||||||
loadNextPage()
|
rv.post { loadNextPage() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -138,19 +145,20 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
when {
|
when {
|
||||||
isMib() -> {
|
isMib() -> {
|
||||||
val session = app.mibSession ?: return@withContext emptyList()
|
val session = app.mibSession ?: return@withContext emptyList()
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
app.mibMutex.withLock {
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
||||||
val client = MibHistoryClient()
|
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||||
val (list, total) = client.fetchHistory(
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
session = session,
|
session = session,
|
||||||
accountNo = account.accountNumber,
|
accountNo = account.accountNumber,
|
||||||
accountDisplayName = account.accountBriefName,
|
accountDisplayName = account.accountBriefName,
|
||||||
start = mibNextStart,
|
start = mibNextStart,
|
||||||
pageSize = pageSize
|
pageSize = pageSize
|
||||||
)
|
)
|
||||||
if (total > 0) mibTotalCount = total
|
if (total > 0) mibTotalCount = total
|
||||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
list
|
list
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isBmlCard() -> {
|
isBmlCard() -> {
|
||||||
val session = app.bmlSession ?: return@withContext emptyList()
|
val session = app.bmlSession ?: return@withContext emptyList()
|
||||||
@@ -196,7 +204,14 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
allTransactions.addAll(newOnes)
|
allTransactions.addAll(newOnes)
|
||||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
||||||
filterAndDisplay()
|
if (searchQuery.isBlank()) {
|
||||||
|
// Append incrementally to preserve scroll position
|
||||||
|
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
|
adapter.appendTransactions(sorted)
|
||||||
|
binding.emptyView.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
adapter.showLoadingFooter = false
|
adapter.showLoadingFooter = false
|
||||||
}
|
}
|
||||||
@@ -208,6 +223,30 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadContactImage(name: String) {
|
||||||
|
if (!pendingImageNames.add(name)) return
|
||||||
|
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
||||||
|
val hash = contact.customerImgHash ?: return
|
||||||
|
val cached = ContactImageCache.load(requireContext(), hash)
|
||||||
|
if (cached != null) {
|
||||||
|
binding.recyclerView.post { adapter.updateImage(name, cached) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val sess = app.mibSession ?: return
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
|
ContactImageCache.save(requireContext(), hash, bitmap)
|
||||||
|
withContext(Dispatchers.Main) { adapter.updateImage(name, bitmap) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
pendingImageNames.remove(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import sh.sar.basedbank.api.mib.MibBeneficiary
|
|||||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
|
||||||
class ContactsFragment : Fragment() {
|
class ContactsFragment : Fragment() {
|
||||||
@@ -212,6 +213,12 @@ class ContactsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun fetchImage(hash: String) {
|
private fun fetchImage(hash: String) {
|
||||||
if (!pendingHashes.add(hash)) return
|
if (!pendingHashes.add(hash)) return
|
||||||
|
// Check disk cache first — if hash matches we already have the image
|
||||||
|
val cached = ContactImageCache.load(requireContext(), hash)
|
||||||
|
if (cached != null) {
|
||||||
|
view?.post { pagerAdapter.updateImage(hash, cached) }
|
||||||
|
return
|
||||||
|
}
|
||||||
val sess = session ?: return
|
val sess = session ?: return
|
||||||
val client = MibContactsClient()
|
val client = MibContactsClient()
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
@@ -219,6 +226,7 @@ class ContactsFragment : Fragment() {
|
|||||||
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
|
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
|
ContactImageCache.save(requireContext(), hash, bitmap)
|
||||||
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
pendingHashes.remove(hash)
|
pendingHashes.remove(hash)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
import sh.sar.basedbank.api.mib.Transaction
|
||||||
@@ -21,6 +23,16 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
|
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
|
|
||||||
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
|
imageCache[counterpartyName] = bitmap
|
||||||
|
displayItems.forEachIndexed { i, item ->
|
||||||
|
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
||||||
|
notifyItemChanged(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var _showLoadingFooter = false
|
private var _showLoadingFooter = false
|
||||||
var showLoadingFooter: Boolean
|
var showLoadingFooter: Boolean
|
||||||
@@ -39,7 +51,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
|
|
||||||
/** Replace the full sorted transaction list and rebuild date groups. */
|
/** Replace the full sorted transaction list and rebuild date groups. */
|
||||||
fun setTransactions(transactions: List<Transaction>) {
|
fun setTransactions(transactions: List<Transaction>) {
|
||||||
_showLoadingFooter = false // reset silently; notifyDataSetChanged covers it
|
_showLoadingFooter = false
|
||||||
displayItems.clear()
|
displayItems.clear()
|
||||||
var lastDateKey = ""
|
var lastDateKey = ""
|
||||||
for (trx in transactions) {
|
for (trx in transactions) {
|
||||||
@@ -53,10 +65,10 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = displayItems.size + if (showLoadingFooter) 1 else 0
|
override fun getItemCount() = displayItems.size + if (_showLoadingFooter) 1 else 0
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) = when {
|
override fun getItemViewType(position: Int) = when {
|
||||||
showLoadingFooter && position == displayItems.size -> TYPE_LOADING
|
_showLoadingFooter && position == displayItems.size -> TYPE_LOADING
|
||||||
displayItems[position] is Item.DateHeader -> TYPE_DATE_HEADER
|
displayItems[position] is Item.DateHeader -> TYPE_DATE_HEADER
|
||||||
else -> TYPE_TRANSACTION
|
else -> TYPE_TRANSACTION
|
||||||
}
|
}
|
||||||
@@ -88,15 +100,25 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
fun bind(trx: Transaction) {
|
fun bind(trx: Transaction) {
|
||||||
val isCredit = trx.amount >= 0
|
val isCredit = trx.amount >= 0
|
||||||
val color = AccountHistoryAdapter.sourceColor(trx.source)
|
val color = AccountHistoryAdapter.sourceColor(trx.source)
|
||||||
val initial = (trx.counterpartyName ?: trx.description)
|
val name = trx.counterpartyName ?: trx.description
|
||||||
.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
val circle = GradientDrawable().apply {
|
val bitmap = trx.counterpartyName?.let { imageCache[it] }
|
||||||
shape = GradientDrawable.OVAL
|
if (bitmap != null) {
|
||||||
setColor(Color.parseColor(color))
|
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
|
||||||
|
rd.isCircular = true
|
||||||
|
b.fvAvatar.background = rd
|
||||||
|
b.tvInitial.visibility = View.INVISIBLE
|
||||||
|
} else {
|
||||||
|
val circle = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(Color.parseColor(color))
|
||||||
|
}
|
||||||
|
b.fvAvatar.background = circle
|
||||||
|
b.tvInitial.visibility = View.VISIBLE
|
||||||
|
b.tvInitial.text = initial
|
||||||
|
if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
|
||||||
}
|
}
|
||||||
b.fvAvatar.background = circle
|
|
||||||
b.tvInitial.text = initial
|
|
||||||
b.tvDescription.text = trx.description
|
b.tvDescription.text = trx.description
|
||||||
|
|
||||||
// Show account name in secondary line for Transfer History
|
// Show account name in secondary line for Transfer History
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -15,15 +17,18 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
import sh.sar.basedbank.api.mib.Transaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -60,6 +65,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
private var isLoading = false
|
private var isLoading = false
|
||||||
private var firstBatchDone = false
|
private var firstBatchDone = false
|
||||||
private val pageSize = 20
|
private val pageSize = 20
|
||||||
|
private val pendingImageNames = mutableSetOf<String>()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
|
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
|
||||||
@@ -68,6 +74,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
adapter = TransactionAdapter()
|
adapter = TransactionAdapter()
|
||||||
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
@@ -76,7 +83,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
if (dy <= 0 || isLoading) return
|
if (dy <= 0 || isLoading) return
|
||||||
val lm = rv.layoutManager as LinearLayoutManager
|
val lm = rv.layoutManager as LinearLayoutManager
|
||||||
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 5) {
|
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 5) {
|
||||||
loadNextPages()
|
rv.post { loadNextPages() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -171,25 +178,27 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.awaitAll().flatten())
|
||||||
|
|
||||||
// MIB accounts: serialized per profile to avoid session race
|
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||||
val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") }
|
val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") }
|
||||||
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
|
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
|
||||||
val session = mibSession ?: break
|
val session = mibSession ?: break
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
|
app.mibMutex.withLock {
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
|
||||||
for (state in states) {
|
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||||
try {
|
for (state in states) {
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
try {
|
||||||
session = session,
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
accountNo = state.account.accountNumber,
|
session = session,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
accountNo = state.account.accountNumber,
|
||||||
start = state.mibNextStart,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
pageSize = pageSize
|
start = state.mibNextStart,
|
||||||
)
|
pageSize = pageSize
|
||||||
if (total > 0) state.mibTotalCount = total
|
)
|
||||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
if (total > 0) state.mibTotalCount = total
|
||||||
results.addAll(list)
|
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
} catch (_: Exception) {}
|
results.addAll(list)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +241,30 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadContactImage(name: String) {
|
||||||
|
if (!pendingImageNames.add(name)) return
|
||||||
|
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
||||||
|
val hash = contact.customerImgHash ?: return
|
||||||
|
val cached = ContactImageCache.load(requireContext(), hash)
|
||||||
|
if (cached != null) {
|
||||||
|
binding.recyclerView.post { adapter.updateImage(name, cached) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val sess = app.mibSession ?: return
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
|
ContactImageCache.save(requireContext(), hash, bitmap)
|
||||||
|
withContext(Dispatchers.Main) { adapter.updateImage(name, bitmap) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
pendingImageNames.remove(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object ContactImageCache {
|
||||||
|
|
||||||
|
private fun file(context: Context, hash: String) =
|
||||||
|
File(context.cacheDir, "cimg_${hash.replace(Regex("[^A-Za-z0-9]"), "_")}.png")
|
||||||
|
|
||||||
|
fun save(context: Context, hash: String, bitmap: Bitmap) {
|
||||||
|
try {
|
||||||
|
file(context, hash).outputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 90, it)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(context: Context, hash: String): Bitmap? = try {
|
||||||
|
BitmapFactory.decodeFile(file(context, hash).absolutePath)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user