add transactions and account history
Auto Tag on Version Change / check-version (push) Successful in 3s
Auto Tag on Version Change / check-version (push) Successful in 3s
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryAdapter(
|
||||
private val account: MibAccount
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: Transaction) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
|
||||
private var _showLoadingFooter = false
|
||||
var showLoadingFooter: Boolean
|
||||
get() = _showLoadingFooter
|
||||
set(value) {
|
||||
if (_showLoadingFooter == value) return
|
||||
if (value) {
|
||||
_showLoadingFooter = true
|
||||
notifyItemInserted(itemCount - 1)
|
||||
} else {
|
||||
val pos = itemCount - 1
|
||||
_showLoadingFooter = false
|
||||
notifyItemRemoved(pos)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the given (already sorted + filtered) list with date group headers.
|
||||
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
||||
*/
|
||||
fun setTransactions(transactions: List<Transaction>) {
|
||||
_showLoadingFooter = false
|
||||
displayItems.clear()
|
||||
var lastDateKey = ""
|
||||
for (trx in transactions) {
|
||||
val dateKey = trx.date.take(10)
|
||||
if (dateKey != lastDateKey) {
|
||||
displayItems.add(Item.DateHeader(formatDateHeader(trx.date)))
|
||||
lastDateKey = dateKey
|
||||
}
|
||||
displayItems.add(Item.Trx(trx))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
// Position 0 = account header card
|
||||
// Positions 1..displayItems.size = date headers + transactions
|
||||
// Last position = loading footer when showLoadingFooter = true
|
||||
override fun getItemCount() = 1 + displayItems.size + if (_showLoadingFooter) 1 else 0
|
||||
|
||||
override fun getItemViewType(position: Int) = when {
|
||||
position == 0 -> TYPE_HEADER
|
||||
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
||||
else -> when (displayItems[position - 1]) {
|
||||
is Item.DateHeader -> TYPE_DATE_HEADER
|
||||
is Item.Trx -> TYPE_TRANSACTION
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
TYPE_HEADER -> HeaderVH(ItemAccountHistoryHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_DATE_HEADER -> DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_LOADING -> LoadingVH(ItemLoadingFooterBinding.inflate(inflater, parent, false))
|
||||
else -> TransactionVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is HeaderVH -> holder.bind(account)
|
||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(acc: MibAccount) {
|
||||
b.tvHeaderAccountName.text = acc.accountBriefName
|
||||
b.tvHeaderAccountNumber.text = acc.accountNumber
|
||||
b.tvHeaderPillBank.text = if (acc.profileType.startsWith("BML")) "BML" else "MIB"
|
||||
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
|
||||
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
|
||||
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
|
||||
if (blocked > 0.0) {
|
||||
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
|
||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.llHeaderBlocked.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun friendlyType(raw: String): String {
|
||||
val u = raw.trim().uppercase()
|
||||
return when {
|
||||
u.contains("SAVING") -> "Savings"
|
||||
u.contains("CURRENT") -> "Current"
|
||||
u.contains("WADIAH") -> "Islamic"
|
||||
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
|
||||
u.contains("PREPAID") -> "Prepaid"
|
||||
else -> raw.trim().take(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||
}
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: Transaction) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = sourceColor(trx.source)
|
||||
val initial = (trx.counterpartyName ?: trx.description)
|
||||
.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
|
||||
val circle = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(Color.parseColor(color))
|
||||
}
|
||||
b.fvAvatar.background = circle
|
||||
b.tvInitial.text = initial
|
||||
b.tvDescription.text = trx.description
|
||||
|
||||
val counterparty = trx.counterpartyName
|
||||
if (!counterparty.isNullOrBlank()) {
|
||||
b.tvCounterparty.text = counterparty
|
||||
b.tvCounterparty.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.tvCounterparty.visibility = View.GONE
|
||||
}
|
||||
|
||||
b.tvDate.text = formatTime(trx.date)
|
||||
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
|
||||
b.root.setOnClickListener { showDetail(trx) }
|
||||
}
|
||||
|
||||
private fun showDetail(trx: Transaction) {
|
||||
val ctx = b.root.context
|
||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||
val details = buildString {
|
||||
if (!trx.counterpartyName.isNullOrBlank()) append("Type\n${trx.description}\n\n")
|
||||
val sign = if (trx.amount >= 0) "+" else "-"
|
||||
append("Amount\n$sign ${trx.currency} ${"%.2f".format(kotlin.math.abs(trx.amount))}\n\n")
|
||||
append("Date\n${formatFullDate(trx.date)}\n\n")
|
||||
if (!trx.reference.isNullOrBlank()) append("Reference\n${trx.reference}\n\n")
|
||||
append("Account\n${trx.accountDisplayName}")
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(title)
|
||||
.setMessage(details)
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
inner class LoadingVH(b: ItemLoadingFooterBinding) : RecyclerView.ViewHolder(b.root)
|
||||
|
||||
companion object {
|
||||
const val TYPE_HEADER = 0
|
||||
const val TYPE_TRANSACTION = 1
|
||||
const val TYPE_LOADING = 2
|
||||
const val TYPE_DATE_HEADER = 3
|
||||
|
||||
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
|
||||
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
||||
|
||||
fun parseDateMillis(raw: String): Long {
|
||||
if (raw.isBlank()) return 0L
|
||||
return try {
|
||||
if (raw.contains("T")) BML_FMT.parse(raw.take(25))?.time ?: 0L
|
||||
else MIB_FMT.parse(raw)?.time ?: 0L
|
||||
} catch (_: Exception) { 0L }
|
||||
}
|
||||
|
||||
private fun parseDate(raw: String): Date? = try {
|
||||
if (raw.contains("T")) BML_FMT.parse(raw.take(25))
|
||||
else MIB_FMT.parse(raw)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
fun formatDateHeader(raw: String): String {
|
||||
val date = parseDate(raw) ?: return raw.take(10)
|
||||
return DATE_HEADER_FMT.format(date)
|
||||
}
|
||||
|
||||
fun formatTime(raw: String): String {
|
||||
val date = parseDate(raw) ?: return ""
|
||||
return TIME_FMT.format(date)
|
||||
}
|
||||
|
||||
fun formatFullDate(raw: String): String {
|
||||
val date = parseDate(raw) ?: return raw
|
||||
return FULL_DATE_FMT.format(date)
|
||||
}
|
||||
|
||||
fun sourceColor(source: String) = when (source) {
|
||||
"MIB" -> "#FE860E"
|
||||
"BML", "BML_CARD" -> "#0066A1"
|
||||
else -> "#888888"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentAccountHistoryBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private lateinit var adapter: AccountHistoryAdapter
|
||||
private lateinit var account: MibAccount
|
||||
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private var searchQuery = ""
|
||||
private var firstPageDone = false
|
||||
|
||||
// Pagination state
|
||||
private var mibNextStart = 1
|
||||
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
|
||||
private var bmlNextPage = 1
|
||||
private var bmlTotalPages = -1
|
||||
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
|
||||
private var isLoading = false
|
||||
private val pageSize = 10
|
||||
|
||||
companion object {
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
|
||||
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentAccountHistoryBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
||||
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
||||
|
||||
adapter = AccountHistoryAdapter(account)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||
if (dy <= 0 || isLoading) return
|
||||
val lm = rv.layoutManager as LinearLayoutManager
|
||||
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 3) {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.etSearch.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
searchQuery = s?.toString()?.trim() ?: ""
|
||||
filterAndDisplay()
|
||||
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
// Load cache immediately, then fetch fresh data in background
|
||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPage()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::account.isInitialized) requireActivity().title = account.accountBriefName
|
||||
}
|
||||
|
||||
private fun filterAndDisplay() {
|
||||
val filtered = if (searchQuery.isBlank()) allTransactions
|
||||
else allTransactions.filter {
|
||||
it.counterpartyName?.contains(searchQuery, ignoreCase = true) == true ||
|
||||
it.description.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
adapter.setTransactions(filtered)
|
||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun isMib() = !account.profileType.startsWith("BML")
|
||||
private fun isBmlCard() = account.profileType == "BML_PREPAID"
|
||||
|
||||
private fun hasMore(): Boolean = when {
|
||||
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
if (isLoading || !hasMore()) return
|
||||
isLoading = true
|
||||
|
||||
if (firstPageDone && allTransactions.isNotEmpty()) {
|
||||
adapter.showLoadingFooter = true
|
||||
}
|
||||
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
|
||||
when {
|
||||
isMib() -> {
|
||||
val session = app.mibSession ?: return@withContext emptyList()
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||
val client = MibHistoryClient()
|
||||
val (list, total) = client.fetchHistory(
|
||||
session = session,
|
||||
accountNo = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
start = mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) mibTotalCount = total
|
||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
list
|
||||
}
|
||||
isBmlCard() -> {
|
||||
val session = app.bmlSession ?: return@withContext emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
cardMonthOffset++
|
||||
BmlLoginFlow().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSession ?: return@withContext emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
page = bmlNextPage
|
||||
)
|
||||
if (totalPages > 0) bmlTotalPages = totalPages
|
||||
bmlNextPage++
|
||||
list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
||||
if (!firstPageDone) {
|
||||
firstPageDone = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
}
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = transactions.filter { it.id !in existingIds }
|
||||
if (newOnes.isNotEmpty()) {
|
||||
allTransactions.addAll(newOnes)
|
||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
}
|
||||
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,10 @@ import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding
|
||||
|
||||
class AccountsAdapter(accounts: List<MibAccount>) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
class AccountsAdapter(
|
||||
accounts: List<MibAccount>,
|
||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class SectionTitle(val label: String, val chip: String) : Item()
|
||||
@@ -97,6 +99,7 @@ class AccountsAdapter(accounts: List<MibAccount>) :
|
||||
else -> account.profileName
|
||||
}
|
||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
binding.root.setOnLongClickListener {
|
||||
copyToClipboard(it.context, account.accountNumber)
|
||||
true
|
||||
@@ -128,6 +131,7 @@ class AccountsAdapter(accounts: List<MibAccount>) :
|
||||
binding.tvCardStatus.visibility = View.VISIBLE
|
||||
binding.root.alpha = 0.45f
|
||||
}
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ class AccountsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = AccountsAdapter(emptyList())
|
||||
adapter = AccountsAdapter(emptyList()) { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
R.id.nav_add_account -> startActivity(Intent(this, LoginActivity::class.java))
|
||||
R.id.nav_accounts -> show(AccountsFragment())
|
||||
R.id.nav_contacts -> show(ContactsFragment())
|
||||
R.id.nav_transfer_history -> show(TransferHistoryFragment())
|
||||
R.id.nav_finances -> show(FinancingFragment())
|
||||
R.id.nav_settings -> show(SettingsFragment())
|
||||
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
@@ -148,6 +149,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun setRefreshing(visible: Boolean) {
|
||||
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
fun showWithBackStack(fragment: Fragment) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.contentFrame, fragment)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||
|
||||
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
|
||||
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: Transaction) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
|
||||
private var _showLoadingFooter = false
|
||||
var showLoadingFooter: Boolean
|
||||
get() = _showLoadingFooter
|
||||
set(value) {
|
||||
if (_showLoadingFooter == value) return
|
||||
if (value) {
|
||||
_showLoadingFooter = true
|
||||
notifyItemInserted(itemCount - 1)
|
||||
} else {
|
||||
val pos = itemCount - 1
|
||||
_showLoadingFooter = false
|
||||
notifyItemRemoved(pos)
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the full sorted transaction list and rebuild date groups. */
|
||||
fun setTransactions(transactions: List<Transaction>) {
|
||||
_showLoadingFooter = false // reset silently; notifyDataSetChanged covers it
|
||||
displayItems.clear()
|
||||
var lastDateKey = ""
|
||||
for (trx in transactions) {
|
||||
val dateKey = trx.date.take(10)
|
||||
if (dateKey != lastDateKey) {
|
||||
displayItems.add(Item.DateHeader(AccountHistoryAdapter.formatDateHeader(trx.date)))
|
||||
lastDateKey = dateKey
|
||||
}
|
||||
displayItems.add(Item.Trx(trx))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemCount() = displayItems.size + if (showLoadingFooter) 1 else 0
|
||||
|
||||
override fun getItemViewType(position: Int) = when {
|
||||
showLoadingFooter && position == displayItems.size -> TYPE_LOADING
|
||||
displayItems[position] is Item.DateHeader -> TYPE_DATE_HEADER
|
||||
else -> TYPE_TRANSACTION
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
TYPE_DATE_HEADER -> DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_LOADING -> LoadingVH(ItemLoadingFooterBinding.inflate(inflater, parent, false))
|
||||
else -> TransactionVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||
is TransactionVH -> holder.bind((displayItems[position] as Item.Trx).transaction)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||
}
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: Transaction) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = AccountHistoryAdapter.sourceColor(trx.source)
|
||||
val initial = (trx.counterpartyName ?: trx.description)
|
||||
.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
|
||||
val circle = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(Color.parseColor(color))
|
||||
}
|
||||
b.fvAvatar.background = circle
|
||||
b.tvInitial.text = initial
|
||||
b.tvDescription.text = trx.description
|
||||
|
||||
// Show account name in secondary line for Transfer History
|
||||
b.tvCounterparty.text = trx.accountDisplayName
|
||||
b.tvCounterparty.visibility = View.VISIBLE
|
||||
|
||||
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
||||
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
|
||||
b.root.setOnClickListener { showDetail(trx) }
|
||||
}
|
||||
|
||||
private fun showDetail(trx: Transaction) {
|
||||
val ctx = b.root.context
|
||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||
val details = buildString {
|
||||
if (!trx.counterpartyName.isNullOrBlank()) append("Type\n${trx.description}\n\n")
|
||||
val sign = if (trx.amount >= 0) "+" else "-"
|
||||
append("Amount\n$sign ${trx.currency} ${"%.2f".format(kotlin.math.abs(trx.amount))}\n\n")
|
||||
append("Date\n${AccountHistoryAdapter.formatFullDate(trx.date)}\n\n")
|
||||
if (!trx.reference.isNullOrBlank()) append("Reference\n${trx.reference}\n\n")
|
||||
append("Account\n${trx.accountDisplayName}")
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(title)
|
||||
.setMessage(details)
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
inner class LoadingVH(b: ItemLoadingFooterBinding) : RecyclerView.ViewHolder(b.root)
|
||||
|
||||
companion object {
|
||||
const val TYPE_TRANSACTION = 0
|
||||
const val TYPE_LOADING = 1
|
||||
const val TYPE_DATE_HEADER = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
class TransferHistoryFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentTransferHistoryBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private lateinit var adapter: TransactionAdapter
|
||||
|
||||
// All merged transactions sorted by date desc
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private var searchQuery = ""
|
||||
|
||||
// Per-account pagination state
|
||||
private data class AccountState(
|
||||
val account: MibAccount,
|
||||
var mibNextStart: Int = 1,
|
||||
var mibTotalCount: Int = -1,
|
||||
var bmlNextPage: Int = 1,
|
||||
var bmlTotalPages: Int = -1,
|
||||
var cardMonthOffset: Int = 0
|
||||
) {
|
||||
fun hasMore(): Boolean = when {
|
||||
account.profileType == "BML_PREPAID" -> cardMonthOffset < 2
|
||||
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
}
|
||||
}
|
||||
|
||||
private val accountStates = mutableListOf<AccountState>()
|
||||
private var isLoading = false
|
||||
private var firstBatchDone = false
|
||||
private val pageSize = 20
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = TransactionAdapter()
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||
if (dy <= 0 || isLoading) return
|
||||
val lm = rv.layoutManager as LinearLayoutManager
|
||||
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 5) {
|
||||
loadNextPages()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.etSearch.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
searchQuery = s?.toString()?.trim() ?: ""
|
||||
filterAndDisplay()
|
||||
if (searchQuery.isNotBlank() && accountStates.any { it.hasMore() } && !isLoading) {
|
||||
loadNextPages()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
if (accounts.isEmpty()) {
|
||||
binding.emptyView.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
accountStates.clear()
|
||||
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||
|
||||
// Load cache immediately, then fetch fresh data in background
|
||||
val cached = TransactionCache.load(requireContext(), "transfer")
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPages()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_transfer_history)
|
||||
}
|
||||
|
||||
private fun loadNextPages() {
|
||||
val activeStates = accountStates.filter { it.hasMore() }
|
||||
if (isLoading || activeStates.isEmpty()) return
|
||||
isLoading = true
|
||||
|
||||
if (firstBatchDone && allTransactions.isNotEmpty()) {
|
||||
adapter.showLoadingFooter = true
|
||||
}
|
||||
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val mibSession = app.mibSession
|
||||
val bmlSession = app.bmlSession
|
||||
|
||||
lifecycleScope.launch {
|
||||
val newTransactions = withContext(Dispatchers.IO) {
|
||||
val results = mutableListOf<Transaction>()
|
||||
|
||||
// BML accounts: fetch in parallel
|
||||
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
|
||||
results.addAll(bmlStates.map { state ->
|
||||
async {
|
||||
try {
|
||||
when {
|
||||
state.account.profileType == "BML_PREPAID" -> {
|
||||
val session = bmlSession ?: return@async emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
state.cardMonthOffset++
|
||||
BmlLoginFlow().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
accountNumber = state.account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val session = bmlSession ?: return@async emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
accountNumber = state.account.accountNumber,
|
||||
page = state.bmlNextPage
|
||||
)
|
||||
if (totalPages > 0) state.bmlTotalPages = totalPages
|
||||
state.bmlNextPage++
|
||||
list
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { emptyList<Transaction>() }
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
// MIB accounts: serialized per profile to avoid session race
|
||||
val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") }
|
||||
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
|
||||
val session = mibSession ?: break
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||
for (state in states) {
|
||||
try {
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
start = state.mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) state.mibTotalCount = total
|
||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
results.addAll(list)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
||||
if (!firstBatchDone) {
|
||||
firstBatchDone = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
}
|
||||
|
||||
if (newTransactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = newTransactions.filter { it.id !in existingIds }
|
||||
if (newOnes.isNotEmpty()) {
|
||||
allTransactions.addAll(newOnes)
|
||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||
TransactionCache.save(requireContext(), "transfer", allTransactions)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
}
|
||||
if (searchQuery.isNotBlank() && accountStates.any { it.hasMore() }) loadNextPages()
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterAndDisplay() {
|
||||
val filtered = if (searchQuery.isBlank()) allTransactions
|
||||
else allTransactions.filter {
|
||||
it.counterpartyName?.contains(searchQuery, ignoreCase = true) == true ||
|
||||
it.description.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
adapter.setTransactions(filtered)
|
||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user