added support for BML loans
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-05-21 22:58:58 +05:00
parent 50150b826f
commit e82218e897
12 changed files with 644 additions and 46 deletions

View File

@@ -49,6 +49,30 @@ class BmlAccountClient {
} catch (_: Exception) { null }
}
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
val code = resp.code
val json = resp.body?.string() ?: return null
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val p = root.optJSONObject("payload") ?: return null
BmlLoanDetail(
loanAmount = p.optDouble("loanAmount", 0.0),
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
repayAmount = p.optDouble("repayAmount", 0.0),
intRate = p.optDouble("intRate", 0.0),
loanStatus = p.optString("loanStatus"),
startDate = p.optString("startDate"),
endDate = p.optString("endDate"),
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
overdueAmount = p.optDouble("overdueAmount", 0.0)
)
} catch (_: Exception) { null }
}
private fun parseDashboard(
json: String,
loginTag: String,
@@ -61,6 +85,7 @@ class BmlAccountClient {
val casaAccounts = mutableListOf<BankAccount>()
val prepaidCards = mutableListOf<BankAccount>()
val loanAccounts = mutableListOf<BankAccount>()
for (i in 0 until dashboard.length()) {
val item = dashboard.getJSONObject(i)
@@ -91,6 +116,26 @@ class BmlAccountClient {
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Loan") {
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
loanAccounts.add(BankAccount(
bank = "BML",
profileName = profileName,
profileType = "BML_LOAN",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(outstanding),
currentBalance = "%.2f".format(outstanding),
blockedAmount = "0.00",
mvrBalance = "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue
@@ -119,6 +164,6 @@ class BmlAccountClient {
}
}
return casaAccounts + prepaidCards
return casaAccounts + prepaidCards + loanAccounts
}
}

View File

@@ -50,6 +50,18 @@ data class BmlTransferResult(
val errorMessage: String = ""
)
data class BmlLoanDetail(
val loanAmount: Double,
val outstandingAmt: Double, // negative as returned by API
val repayAmount: Double,
val intRate: Double,
val loanStatus: String,
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
val endDate: String,
val noOfRepayOverdue: Int,
val overdueAmount: Double
)
data class BmlForeignLimit(
val type: String,
val used: Double,

View File

@@ -14,6 +14,7 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -30,11 +31,12 @@ class DashboardFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances(viewModel.financing.value ?: emptyList())
updatePendingFinances()
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
}
@@ -123,9 +125,13 @@ class DashboardFragment : Fragment() {
}
}
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
private fun updatePendingFinances() {
val hide = viewModel.hideAmounts.value ?: false
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
val total = mibTotal + bmlTotal
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
}
override fun onDestroyView() {

View File

@@ -5,56 +5,97 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class Mib(val deal: MibFinanceDeal) : Item()
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
}
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
private var hideAmounts: Boolean = false
private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun updateDeals(newDeals: List<MibFinanceDeal>) {
deals = newDeals
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
expandedPositions.clear()
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
// Legacy compatibility — used on initial empty construction
fun updateDeals(newDeals: List<MibFinanceDeal>) {
expandedPositions.clear()
val bmlItems = items.filterIsInstance<Item.Bml>()
items = newDeals.map { Item.Mib(it) } + bmlItems
notifyDataSetChanged()
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(deals[position], position in expandedPositions)
holder.binding.root.setOnClickListener {
fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
expandedPositions.clear()
val mibItems = items.filterIsInstance<Item.Mib>()
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
}
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.Mib -> TYPE_MIB
is Item.Bml -> TYPE_BML
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val expanded = position in expandedPositions
when (val item = items[position]) {
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
}
holder.itemView.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
notifyItemChanged(pos)
}
}
override fun getItemCount() = deals.size
override fun getItemCount() = items.size
inner class ViewHolder(val binding: ItemFinanceDealBinding) :
// ── MIB ViewHolder ────────────────────────────────────────────────────────
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
@@ -69,25 +110,22 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0
binding.progressBar.progress = if (hide) 0 else progress
// Completion estimate
binding.tvCompletion.text = completionText(deal, ctx)
binding.tvCompletion.text = mibCompletionText(deal, ctx)
// Expanded details
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.dividerDetails.visibility = detailsVisible
binding.detailsGroup.visibility = detailsVisible
if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate)
binding.tvDealDate.text = formatMibDate(deal.dealDate)
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
binding.tvLastPaidDate.text = formatMibDate(deal.lastPaidDate)
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
if (deal.overdueAmount > 0) {
@@ -99,7 +137,7 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
}
}
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
val remaining = MibFinancingClient.remainingMonths(deal)
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
@@ -109,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
return ctx.getString(R.string.financing_completion_fmt, month)
}
private fun formatDate(raw: String): String {
private fun formatMibDate(raw: String): String {
return try {
outputDateFmt.format(inputDateFmt.parse(raw)!!)
} catch (_: Exception) {
raw.take(10)
}
outputDateFmt.format(mibDateFmt.parse(raw)!!)
} catch (_: Exception) { raw.take(10) }
}
}
// ── BML ViewHolder ────────────────────────────────────────────────────────
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
val ctx = binding.root.context
val currency = account.currencyName
val hide = hideAmounts
binding.tvLoanProduct.text = account.accountTypeName
.trim().lowercase().split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
binding.tvLoanAccount.text = account.accountNumber
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
val loanAmt = detail?.loanAmount ?: 0.0
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
binding.loanProgressBar.progress = if (hide) 0 else progress
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.loanDividerDetails.visibility = detailsVisible
binding.loanDetailsGroup.visibility = detailsVisible
if (expanded && detail != null) {
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
if (detail.overdueAmount > 0) {
binding.loanRowOverdue.visibility = View.VISIBLE
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
} else {
binding.loanRowOverdue.visibility = View.GONE
}
}
}
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
if (detail == null) return ""
val outstanding = abs(detail.outstandingAmt)
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
return ctx.getString(R.string.financing_completion_done)
val remaining = ceil(outstanding / detail.repayAmount).toInt()
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, remaining)
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
return ctx.getString(R.string.financing_completion_fmt, month)
}
private fun formatIsoDate(raw: String): String {
return try {
outputDateFmt.format(isoDateFmt.parse(raw)!!)
} catch (_: Exception) { raw.take(10) }
}
}
companion object {
private const val TYPE_MIB = 0
private const val TYPE_BML = 1
}
}

View File

@@ -10,6 +10,9 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentFinancingBinding
class FinancingFragment : Fragment() {
@@ -20,6 +23,9 @@ class FinancingFragment : Fragment() {
private lateinit var adapter: FinancingAdapter
private var financingRefreshing = false
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
return binding.root
@@ -44,19 +50,36 @@ class FinancingFragment : Fragment() {
(activity as? HomeActivity)?.triggerRefreshFinancing()
}
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals)
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
if (financingRefreshing) {
financingRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
latestMibDeals = deals
rebuildAdapter()
}
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
latestBmlLoanDetails = details
rebuildAdapter()
}
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
}
private fun rebuildAdapter() {
val accounts = viewModel.accounts.value ?: emptyList()
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
adapter.update(latestMibDeals, bmlLoans)
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
if (financingRefreshing) {
financingRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_finances)

View File

@@ -40,6 +40,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
@@ -170,6 +171,8 @@ class HomeActivity : AppCompatActivity() {
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
@@ -178,6 +181,7 @@ class HomeActivity : AppCompatActivity() {
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshBmlLoanDetails()
} else {
// Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this)
@@ -188,6 +192,8 @@ class HomeActivity : AppCompatActivity() {
if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
@@ -334,6 +340,7 @@ fun applyNavLabelVisibility() {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlLoanDetails()
}
fun setRefreshing(visible: Boolean) {
@@ -661,6 +668,7 @@ fun applyNavLabelVisibility() {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlLoanDetails()
}
}
@@ -898,6 +906,32 @@ fun applyNavLabelVisibility() {
}
}
private fun refreshBmlLoanDetails() {
val app = application as BasedBankApp
val loanAccounts = app.bmlAccounts.filter { it.profileType == "BML_LOAN" }
if (loanAccounts.isEmpty()) return
lifecycleScope.launch {
try {
val details = withContext(Dispatchers.IO) {
val map = mutableMapOf<String, BmlLoanDetail>()
for (acc in loanAccounts) {
val session = app.bmlSessionFor(acc) ?: continue
try {
val detail = BmlAccountClient().fetchLoanDetail(session, acc.internalId)
if (detail != null) map[acc.internalId] = detail
} catch (_: Exception) { /* keep existing */ }
}
map
}
if (details.isNotEmpty()) {
val merged = (viewModel.bmlLoanDetails.value ?: emptyMap()) + details
FinancingCache.saveBmlLoans(this@HomeActivity, merged)
viewModel.bmlLoanDetails.postValue(merged)
}
} catch (_: Exception) { /* keep cached data */ }
}
}
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)

View File

@@ -3,6 +3,7 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
@@ -11,6 +12,8 @@ import sh.sar.basedbank.api.mib.MibFinanceDeal
class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
/** BML loan details keyed by account internalId. */
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
val contacts = MutableLiveData<List<BankContact>>(emptyList())
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())

View File

@@ -3,12 +3,14 @@ package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal
object FinancingCache {
private const val PREFS = "financing_cache"
private const val KEY_MIB = "mib_financing"
private const val KEY_BML_LOANS = "bml_loans"
fun save(context: Context, deals: List<MibFinanceDeal>) {
val arr = JSONArray()
@@ -34,6 +36,52 @@ object FinancingCache {
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
}
fun saveBmlLoans(context: Context, loans: Map<String, BmlLoanDetail>) {
val arr = JSONArray()
for ((internalId, d) in loans) {
arr.put(JSONObject().apply {
put("internalId", internalId)
put("loanAmount", d.loanAmount)
put("outstandingAmt", d.outstandingAmt)
put("repayAmount", d.repayAmount)
put("intRate", d.intRate)
put("loanStatus", d.loanStatus)
put("startDate", d.startDate)
put("endDate", d.endDate)
put("noOfRepayOverdue", d.noOfRepayOverdue)
put("overdueAmount", d.overdueAmount)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_BML_LOANS, CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBmlLoans(context: Context): Map<String, BmlLoanDetail> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML_LOANS, null) ?: return emptyMap()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
buildMap {
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val id = o.optString("internalId")
if (id.isNotBlank()) put(id, BmlLoanDetail(
loanAmount = o.optDouble("loanAmount", 0.0),
outstandingAmt = o.optDouble("outstandingAmt", 0.0),
repayAmount = o.optDouble("repayAmount", 0.0),
intRate = o.optDouble("intRate", 0.0),
loanStatus = o.optString("loanStatus"),
startDate = o.optString("startDate"),
endDate = o.optString("endDate"),
noOfRepayOverdue = o.optInt("noOfRepayOverdue", 0),
overdueAmount = o.optDouble("overdueAmount", 0.0)
))
}
}
} catch (_: Exception) { emptyMap() }
}
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}

View File

@@ -22,6 +22,7 @@ class HistoryFetcher(private val account: BankAccount) {
private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private val isBmlLoan get() = account.profileType == "BML_LOAN"
private val isFahipay get() = account.bank == "FAHIPAY"
// MIB pagination
@@ -40,6 +41,7 @@ class HistoryFetcher(private val account: BankAccount) {
private var fahipayTotal = -1
fun hasMore(): Boolean = when {
isBmlLoan -> false
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3
@@ -47,6 +49,7 @@ class HistoryFetcher(private val account: BankAccount) {
}
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
isBmlLoan -> emptyList()
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }

View File

@@ -10,7 +10,8 @@ object BmlDashboardParser {
* Returns all display fields for an account/card row in the accounts list.
* Handles both BML CASA accounts and BML prepaid/credit cards.
*/
fun displayData(account: BankAccount): AccountListDisplay {
fun displayData(account: BankAccount): AccountListDisplay? {
if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true)

View File

@@ -0,0 +1,304 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Header row: product name + status chip -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvLoanProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLoanAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<TextView
android:id="@+id/tvLoanStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
</LinearLayout>
<!-- Loan amount -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/financing_total"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanTotal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginStart="8dp" />
</LinearLayout>
<!-- Progress bar -->
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loanProgressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- Paid / Outstanding row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/financing_paid"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanPaid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="end">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loan_outstanding"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanOutstanding"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="@color/color_unpaid"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Completion estimate -->
<TextView
android:id="@+id/tvLoanCompletion"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Divider -->
<View
android:id="@+id/loanDividerDetails"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginTop="16dp"
android:layout_marginBottom="12dp"
android:visibility="gone" />
<!-- Expanded details -->
<LinearLayout
android:id="@+id/loanDetailsGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<!-- Monthly repayment -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_monthly_repayment"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanRepayment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Interest rate -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_interest_rate"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanIntRate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Start date -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_start_date"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanStartDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- End date -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_end_date"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanEndDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Overdue (only shown when > 0) -->
<LinearLayout
android:id="@+id/loanRowOverdue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_overdue_payments"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="@color/color_unpaid" />
<TextView
android:id="@+id/tvLoanOverdue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="@color/color_unpaid" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -261,4 +261,13 @@
<string name="financing_completion_done">Fully paid</string>
<string name="financing_deal_no_fmt">Deal #%s</string>
<string name="financing_completion_fmt">Completes %s</string>
<!-- BML Loans -->
<string name="loan_outstanding">Outstanding</string>
<string name="loan_monthly_repayment">Monthly Repayment</string>
<string name="loan_interest_rate">Interest Rate</string>
<string name="loan_start_date">Start Date</string>
<string name="loan_end_date">End Date</string>
<string name="loan_overdue_payments">Overdue Payments</string>
<string name="loan_rate_fmt">%.2f%%</string>
</resources>