refactor codebase to be more module for later adding new banks.. add support for single profile mib accounts.. add suport for disabling mib profiles in settings
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s

This commit is contained in:
2026-05-19 14:48:04 +05:00
parent 89a9731797
commit 782e2e7674
43 changed files with 1085 additions and 418 deletions

View File

@@ -3,11 +3,11 @@
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DIALOG" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
</handle>
</Target>
</DropdownSelection>

View File

@@ -618,6 +618,7 @@ class BmlLoginFlow {
if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount(
bank = "BML",
profileName = "Personal",
profileType = "BML",
accountNumber = accountNumber,
@@ -641,6 +642,7 @@ class BmlLoginFlow {
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount(
bank = "BML",
profileName = "Personal",
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber,

View File

@@ -186,6 +186,7 @@ class FahipayLoginFlow {
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
MibAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY",
accountNumber = profile.walletAccount,

View File

@@ -136,10 +136,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
}
val profiles = parseProfiles(loginResp)
lastSession = session2
lastProfiles = profiles
return fetchAllProfiles(session2, profiles, "mib_$username")
lastProfiles = profiles // keep ALL profiles so settings can show them all
val hidden = credentialStore.getHiddenMibProfileIds()
// When the server already selected the profile and returned balances in A41
// (single-profile case: profileSelected=true), use those accounts directly
// without making an extra P47 call (which the server ignores or rejects).
if (loginResp.optBoolean("profileSelected", false)) {
val a41Balances = loginResp.optJSONArray("accountBalance")
if (a41Balances != null && a41Balances.length() > 0) {
val selectedId = loginResp.optString("selectedProfileId")
val profile = profiles.firstOrNull { it.profileId == selectedId }
?: profiles.firstOrNull()
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
val allAccounts = mutableListOf<MibAccount>()
for (i in 0 until a41Balances.length()) {
val a = a41Balances.getJSONObject(i)
allAccounts.add(
MibAccount(
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
loginTag = "mib_$username",
profileId = profile.profileId
)
)
}
return allAccounts
}
}
}
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
}
// ─── Helpers ─────────────────────────────────────────────────────────────
@@ -271,8 +313,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val a = accountBalances.getJSONObject(i)
allAccounts.add(
MibAccount(
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),

View File

@@ -20,8 +20,10 @@ data class MibProfile(
)
data class MibAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String,
val profileType: String,
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
val accountNumber: String,
val accountBriefName: String,
val currencyName: String,

View File

@@ -11,6 +11,7 @@ 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.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
@@ -20,7 +21,8 @@ import java.util.Date
import java.util.Locale
class AccountHistoryAdapter(
private val account: MibAccount
private val account: MibAccount,
private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
@@ -138,7 +140,7 @@ class AccountHistoryAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind(account)
is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit
@@ -147,37 +149,20 @@ class AccountHistoryAdapter(
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 = when {
acc.profileType.startsWith("BML") -> "BML"
acc.profileType == "FAHIPAY" -> "FP"
else -> null
}
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}"
fun bind(d: AccountHistoryDisplay) {
b.tvHeaderAccountName.text = d.name
b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = d.bankPill
b.tvHeaderPillType.text = d.typeLabel
b.tvHeaderAvailable.text = d.availableBalance
b.tvHeaderBalance.text = d.workingBalance
if (d.blockedBalance != null) {
b.tvHeaderBlocked.text = d.blockedBalance
b.llHeaderBlocked.visibility = View.VISIBLE
} else {
b.llHeaderBlocked.visibility = View.GONE
}
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
}
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)
}
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
}
}

View File

@@ -17,23 +17,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
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.fahipay.FahipayLoginFlow
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.Transaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class AccountHistoryFragment : Fragment() {
@@ -43,21 +38,13 @@ class AccountHistoryFragment : Fragment() {
private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount
private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = ""
private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = mutableSetOf<String>()
// 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 fahipayNextStart = 0
private var fahipayTotal = -1
private var isLoading = false
private val pageSize = 10
@@ -79,8 +66,10 @@ class AccountHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
fetcher = HistoryFetcher(account)
adapter = AccountHistoryAdapter(account)
val historyDisplay = AccountHistoryParser.from(account) ?: return
adapter = AccountHistoryAdapter(account, historyDisplay)
adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc ->
@@ -104,11 +93,10 @@ class AccountHistoryFragment : Fragment() {
override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay()
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
if (searchQuery.isNotBlank() && fetcher.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)
@@ -133,19 +121,8 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
}
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private fun isFahipay() = account.profileType == "FAHIPAY"
private fun hasMore(): Boolean = when {
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
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
if (isLoading || !fetcher.hasMore()) return
isLoading = true
if (firstPageDone && allTransactions.isNotEmpty()) {
@@ -155,68 +132,7 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
when {
isFahipay() -> {
val session = app.fahipaySession ?: return@withContext emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
list
}
isMib() -> {
val session = app.mibSession ?: return@withContext emptyList()
app.mibMutex.withLock {
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val (list, total) = MibHistoryClient().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.bmlSessionFor(account) ?: 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.bmlSessionFor(account) ?: 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
}
}
}
val transactions = fetcher.fetchNextPage(app, pageSize)
isLoading = false
@@ -233,7 +149,6 @@ class AccountHistoryFragment : Fragment() {
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
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
@@ -243,7 +158,7 @@ class AccountHistoryFragment : Fragment() {
} else {
adapter.showLoadingFooter = false
}
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
} else {
adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
@@ -288,8 +203,7 @@ class AccountHistoryFragment : Fragment() {
val response = client.newCall(Request.Builder().url(url).build()).execute()
val bytes = response.body?.bytes() ?: return@launch
response.close()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: return@launch
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
MerchantIconCache.save(requireContext(), url, bitmap)
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
} catch (_: Exception) {

View File

@@ -8,23 +8,24 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.BmlDashboardParser
import sh.sar.basedbank.util.MibAccountParser
import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter(
accounts: List<MibAccount>,
private val onAccountClick: (MibAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((MibAccount) -> Unit)? = null
private sealed class Item {
data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item()
data class Card(val account: MibAccount) : Item()
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
}
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
@@ -36,38 +37,38 @@ class AccountsAdapter(
}
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
val nonCards = displayed.filter { !it.second.isCard }
val cards = displayed.filter { it.second.isCard }
// Group non-prepaid accounts by their derived section title, preserving order
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
for (acc in nonPrepaid) {
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
for ((acc, display) in nonCards) {
val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
}
for ((title, group) in groups) {
add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) }
group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
}
if (prepaid.isNotEmpty()) {
if (cards.isNotEmpty()) {
add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) }
cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
}
}
private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
val bankName = when (account.bank) {
"BML" -> "Bank of Maldives"
"FAHIPAY" -> "Fahipay"
"MIB" -> "Maldives Islamic Bank"
else -> account.bank
}
val bank = when {
account.profileType.startsWith("BML") -> "Bank of Maldives"
account.profileType == "FAHIPAY" -> "Fahipay"
else -> "Maldives Islamic Bank"
val profileLabel = when (account.bank) {
"MIB" -> account.cifType.ifBlank { account.profileName }
else -> account.profileName
}
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
}
override fun getItemViewType(position: Int) = when (items[position]) {
@@ -79,17 +80,17 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
is Item.Card -> (holder as CardViewHolder).bind(item.account)
is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
}
}
@@ -104,18 +105,15 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) {
binding.tvAccountName.text = account.accountBriefName
binding.tvAccountNumber.text = account.accountNumber
val label = if (account.profileType.startsWith("BML"))
BmlDashboardParser.productLabel(account.accountTypeName)
else
MibAccountParser.productLabel(account.accountTypeName)
binding.tvPillType.text = label
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
fun bind(account: MibAccount, display: AccountListDisplay) {
binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = display.number
binding.tvAccountType.text = display.typeLabel
binding.tvBalance.text = display.balance
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber)
copyToClipboard(it.context, display.number)
true
}
}
@@ -123,23 +121,22 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) {
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName))
binding.tvCardName.text = account.accountBriefName
binding.tvCardNumber.text = account.accountNumber
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName)
fun bind(account: MibAccount, display: AccountListDisplay) {
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardName.text = display.name
binding.tvCardNumber.text = display.number
binding.tvCardProduct.text = display.typeLabel
binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
if (isActive) {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} else {
binding.tvCardStatus.text = account.statusDesc
binding.tvCardBalance.text = display.balance
if (display.statusLabel != null) {
binding.tvCardStatus.text = display.statusLabel
binding.tvCardStatus.visibility = View.VISIBLE
binding.root.alpha = 0.45f
} else {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
}
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) }
}
}
@@ -154,13 +151,5 @@ class AccountsAdapter(
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
}
private fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> R.drawable.ic_nav_card
}
}
}

View File

@@ -26,6 +26,9 @@ class AccountsFragment : Fragment() {
adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
}
adapter.onTransferClick = { account ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter

View File

@@ -92,7 +92,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun buildDestinations(): List<DestinationOption> {
val list = mutableListOf<DestinationOption>()
for (profile in app.mibProfiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, subtitle = profile.cifType))
}
val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) {

View File

@@ -12,23 +12,23 @@ import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit
private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList()
private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<ContactDisplay> = emptyList()
private var activeCategoryId: String? = null
private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) {
fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts
applyFilter()
}
@@ -36,7 +36,7 @@ class ContactsAdapter(
fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact ->
if (contact.customerImgHash == hash) notifyItemChanged(index)
if (contact.imageHash == hash) notifyItemChanged(index)
}
}
@@ -48,11 +48,11 @@ class ContactsAdapter(
private fun applyFilter() {
displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery)
contact.name.contains(searchQuery, ignoreCase = true) ||
contact.realName.contains(searchQuery, ignoreCase = true) ||
contact.accountNumber.contains(searchQuery)
matchesCategory && matchesSearch
}
notifyDataSetChanged()
@@ -76,7 +76,7 @@ class ContactsAdapter(
binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
val account = displayed[pos].benefAccount
val account = displayed[pos].accountNumber
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
@@ -88,7 +88,7 @@ class ContactsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash ->
val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage)
@@ -99,21 +99,24 @@ class ContactsAdapter(
inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
val isFahipay = contact.benefType == "FAHIPAY"
binding.tvContactName.text = contact.benefNickName
binding.tvContactAccount.text = contact.benefAccount
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}"
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
fun bind(contact: ContactDisplay, photo: Bitmap?) {
binding.tvContactName.text = contact.name
binding.tvContactAccount.text = contact.accountNumber
binding.tvRealName.text = contact.detail ?: ""
binding.tvRealName.visibility =
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility =
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnEditContact.visibility =
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility =
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo)
} else {
binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
makeInitialsBitmap(contact.name, contact.bankColor)
)
}
}

View File

@@ -21,13 +21,15 @@ 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.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() {
@@ -40,7 +42,7 @@ class ContactsFragment : Fragment() {
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
private var allContacts: List<MibBeneficiary> = emptyList()
private var allContacts: List<ContactDisplay> = emptyList()
private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter
@@ -53,9 +55,9 @@ class ContactsFragment : Fragment() {
private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter(
imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) }
).also { a ->
a.setFilter(page.categoryId, currentSearch)
@@ -63,7 +65,7 @@ class ContactsFragment : Fragment() {
}
}
fun updateContacts(contacts: List<MibBeneficiary>) =
fun updateContacts(contacts: List<ContactDisplay>) =
contactAdapters.forEach { it.updateContacts(contacts) }
fun updateSearch(query: String) =
@@ -124,8 +126,8 @@ class ContactsFragment : Fragment() {
}
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
allContacts = contacts
pagerAdapter.updateContacts(contacts)
allContacts = ContactListParser.fromList(contacts)
pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
@@ -150,31 +152,29 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
}
private fun openTransfer(contact: MibBeneficiary) {
private fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
accountNumber = contact.accountNumber,
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.customerImgHash
imageHash = contact.imageHash
)
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
}
private fun confirmDelete(contact: MibBeneficiary) {
private fun confirmDelete(contact: ContactDisplay) {
AlertDialog.Builder(requireContext())
.setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName))
.setMessage(getString(R.string.contact_delete_message, contact.name))
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteContact(contact: MibBeneficiary) {
private fun deleteContact(contact: ContactDisplay) {
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
}
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact)
@@ -184,27 +184,10 @@ class ContactsFragment : Fragment() {
}
}
private fun deleteBml(contact: MibBeneficiary): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: MibBeneficiary): Boolean {
val sess = session ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.benefNo)
} catch (_: Exception) { false }
}
private fun removeFromViewModel(contact: MibBeneficiary) {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
private fun removeFromViewModel(contact: ContactDisplay) {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") {
if (contact.network == TransferNetwork.BML) {
updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId }
.forEach { (loginId, contacts) ->
@@ -221,7 +204,6 @@ class ContactsFragment : Fragment() {
private fun fetchImage(hash: String) {
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) }

View File

@@ -130,9 +130,9 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background
val mibAccounts = app.accounts.filter { !it.profileType.startsWith("BML") && it.profileType != "FAHIPAY" }
val mibAccounts = app.accounts.filter { it.bank == "MIB" }
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
viewModel.accounts.value = merged
viewModel.accounts.value = merged.filterVisibleAccounts()
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
@@ -145,7 +145,7 @@ class HomeActivity : AppCompatActivity() {
val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
refreshFinancing(app.mibSession, app.mibProfiles)
refreshFinancing(app.mibSession, app.mibProfiles.filterVisibleProfiles())
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
} else {
// Came from lock screen — show caches immediately, refresh everything in background
@@ -364,12 +364,12 @@ class HomeActivity : AppCompatActivity() {
// Immediately drop accounts for logged-out banks from the displayed list
val current = viewModel.accounts.value ?: emptyList()
viewModel.accounts.value = current.filter { acc ->
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false
if (acc.profileType.startsWith("BML")) {
if (!hasMib && acc.bank == "MIB") return@filter false
if (acc.bank == "BML") {
val loginId = acc.loginTag.removePrefix("bml_")
return@filter loginId in bmlLoginIds
}
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
if (!hasFahipay && acc.bank == "FAHIPAY") return@filter false
true
}
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
@@ -395,6 +395,7 @@ class HomeActivity : AppCompatActivity() {
app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles
AccountCache.save(this@HomeActivity, accounts)
CredentialStore(this@HomeActivity).saveMibProfiles(flow.lastProfiles)
accounts
} catch (_: Exception) { AccountCache.load(this@HomeActivity) }
}
@@ -488,14 +489,34 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp
app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts)
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshFinancing(app.mibSession, app.mibProfiles)
refreshFinancing(app.mibSession, app.mibProfiles.filterVisibleProfiles())
}
}
/** Filters MIB accounts whose profileId the user has hidden in settings. */
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds()
if (hidden.isEmpty()) return this
return filter { it.bank != "MIB" || it.profileId !in hidden }
}
/** Filters MIB profiles the user has hidden — prevents API requests for hidden profiles. */
private fun List<MibProfile>.filterVisibleProfiles(): List<MibProfile> {
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds()
if (hidden.isEmpty()) return this
return filter { it.profileId !in hidden }
}
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
fun applyProfileVisibility() {
val current = viewModel.accounts.value ?: return
viewModel.accounts.value = current.filterVisibleAccounts()
}
private fun refreshBmlLimits(session: BmlSession) {
val bmlFlow = BmlLoginFlow()
lifecycleScope.launch {
@@ -550,7 +571,7 @@ class HomeActivity : AppCompatActivity() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
}
// Refresh all banks in background
refreshContacts(app.mibSession, app.mibProfiles)
refreshContacts(app.mibSession, app.mibProfiles.filterVisibleProfiles())
refreshBmlContacts(app)
if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!)
}
@@ -630,7 +651,7 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp
lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList()
if (src.profileType == "FAHIPAY") {
if (src.bank == "FAHIPAY") {
val fresh = withContext(Dispatchers.IO) {
val sess = app.fahipaySession ?: return@withContext null
try {
@@ -645,9 +666,9 @@ class HomeActivity : AppCompatActivity() {
accounts
} catch (_: Exception) { null }
} ?: return@launch
val others = current.filter { it.profileType != "FAHIPAY" }
val others = current.filter { it.bank != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) {
} else if (src.bank == "BML") {
val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null
@@ -659,20 +680,36 @@ class HomeActivity : AppCompatActivity() {
accounts
} catch (_: Exception) { null }
} ?: return@launch
val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag }
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
viewModel.accounts.postValue(otherAccounts + fresh)
} else {
val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
catch (_: Exception) { null }
val store = CredentialStore(this@HomeActivity)
val hidden = store.getHiddenMibProfileIds()
val allVisible = app.mibProfiles.filter { hidden.isEmpty() || it.profileId !in hidden }
// Try P47 for all visible profiles (fast path, works for multi-profile)
val sess = app.mibSession
if (sess != null && allVisible.isNotEmpty()) {
try {
val accounts = MibLoginFlow(store).fetchAllProfiles(sess, allVisible, src.loginTag)
if (accounts.isNotEmpty()) return@withContext accounts
} catch (_: Exception) { }
}
// P47 returned nothing — re-login to get fresh balances (handles single-profile A41 path)
val creds = store.loadMibCredentials() ?: return@withContext null
try {
val flow = MibLoginFlow(store)
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles
store.saveMibProfiles(flow.lastProfiles)
accounts.takeIf { it.isNotEmpty() }
} catch (_: Exception) { null }
} ?: return@launch
// Replace accounts from this profile only, keep everything else
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") }
val merged = others + fresh
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") })
viewModel.accounts.postValue(merged)
// Replace all MIB accounts with fresh data
val others = current.filter { it.bank != "MIB" }
AccountCache.save(this@HomeActivity, fresh)
viewModel.accounts.postValue(others + fresh)
}
}
}

View File

@@ -7,6 +7,7 @@ import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@@ -14,6 +15,7 @@ import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity
@@ -67,22 +69,9 @@ class SettingsLoginsFragment : Fragment() {
if (hasMib) {
val profile = store.loadMibUserProfile()
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
val profileNames = AccountCache.load(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct()
val mibProfiles = store.loadMibProfiles()
addLoginRow(container, R.drawable.mib_logo, displayName) {
showLoginDetails(
title = getString(R.string.mib_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (profileNames.isNotEmpty()) {
appendLine()
appendLine(getString(R.string.login_detail_profiles))
profileNames.forEach { appendLine("$it") }
}
}.trim(),
onLogout = { confirmLogout(getString(R.string.mib_name)) { logoutMib(store) } }
)
showMibLoginDetails(store, profile, mibProfiles)
}
}
@@ -154,6 +143,117 @@ class SettingsLoginsFragment : Fragment() {
container.addView(row)
}
private fun showMibLoginDetails(
store: CredentialStore,
profile: CredentialStore.MibUserProfile?,
mibProfiles: List<MibProfile>
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenMibProfileIds()
val hidden = originalHidden.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
scroll.addView(container)
// Account info lines
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
})
}
if (mibProfiles.isNotEmpty()) {
if (profile != null) {
container.addView(View(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
}
setBackgroundColor(0x1F000000)
})
}
container.addView(TextView(ctx).apply {
text = getString(R.string.login_detail_profiles)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (8 * dp).toInt()
}
})
}
// Build checkbox rows — wired up after dialog.show() so we can reference the Save button
val checkboxRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
textCol.addView(TextView(ctx).apply {
text = p.name
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
})
if (p.cifType.isNotBlank()) {
textCol.addView(TextView(ctx).apply {
text = p.cifType
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
}
val cb = CheckBox(ctx).apply { isChecked = p.profileId !in hidden }
row.addView(textCol)
row.addView(cb)
container.addView(row)
p to cb
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.mib_name))
.setView(scroll)
.setPositiveButton(R.string.save, null) // null — set manually after show()
.setNeutralButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.mib_name)) { logoutMib(store) }
}
.show()
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
checkboxRows.forEach { (p, cb) ->
cb.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
val atLeastOneVisible = mibProfiles.any { it.profileId !in hidden }
saveBtn.isEnabled = hidden != originalHidden && atLeastOneVisible
}
}
saveBtn.setOnClickListener {
store.setHiddenMibProfileIds(hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
}
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(title)

View File

@@ -49,6 +49,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser
@@ -209,16 +210,15 @@ class TransferFragment : Fragment() {
}
private fun showFromCard(account: MibAccount) {
val isBml = account.profileType.startsWith("BML")
val colorHex = when {
isBml -> "#0066A1"
account.profileType == "FAHIPAY" -> "#15BEA7"
else -> "#FE860E"
val colorHex = when (account.bank) {
"BML" -> "#0066A1"
"FAHIPAY" -> "#15BEA7"
else -> "#FE860E"
}
val bankLabel = when {
isBml -> "BML"
account.profileType == "FAHIPAY" -> "FP"
else -> null
val bankLabel = when (account.bank) {
"BML" -> "BML"
"FAHIPAY" -> "FP"
else -> null
}
val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card"
@@ -234,7 +234,7 @@ class TransferFragment : Fragment() {
binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE
if (!isBml && account.profileImageHash != null) {
if (account.bank != "BML" && account.profileImageHash != null) {
loadFromPhoto(account.profileImageHash)
}
}
@@ -312,7 +312,7 @@ class TransferFragment : Fragment() {
}
// Fahipay source: only phone numbers are supported
if (selectedAccount?.profileType == "FAHIPAY") {
if (selectedAccount?.bank == "FAHIPAY") {
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupFahipayTarget(accountNumber)
} else {
@@ -328,7 +328,7 @@ class TransferFragment : Fragment() {
return
}
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
val isBmlSource = selectedAccount?.bank == "BML"
startLookupLoading()
@@ -567,7 +567,7 @@ class TransferFragment : Fragment() {
binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.profileType.startsWith("BML")
val isSrcBml = src.bank == "BML"
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" }
@@ -1009,11 +1009,11 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it }
}
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isBmlAccount = acc.profileType.startsWith("BML")
val isBmlAccount = acc.bank == "BML"
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: ""
b.root.alpha = if (inactive) 0.4f else 1f
b.root
}

View File

@@ -61,10 +61,10 @@ class TransferHistoryFragment : Fragment() {
var fahipayTotal: Int = -1
) {
fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
}
}
@@ -149,7 +149,7 @@ class TransferHistoryFragment : Fragment() {
val results = mutableListOf<Transaction>()
// BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
val bmlStates = activeStates.filter { it.account.bank == "BML" }
results.addAll(bmlStates.map { state ->
async {
try {
@@ -187,7 +187,7 @@ class TransferHistoryFragment : Fragment() {
}.awaitAll().flatten())
// Fahipay accounts
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" }
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
for (state in fahipayStates) {
val session = app.fahipaySession ?: continue
try {
@@ -206,9 +206,7 @@ class TransferHistoryFragment : Fragment() {
}
// MIB accounts: serialized per profile, protected by mutex to prevent session race
val mibStates = activeStates.filter {
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY"
}
val mibStates = activeStates.filter { it.account.bank == "MIB" }
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break
app.mibMutex.withLock {

View File

@@ -194,6 +194,7 @@ class CredentialsFragment : Fragment() {
}
}
AccountCache.save(requireContext(), accounts)
CredentialStore(requireContext()).saveMibProfiles(flow.lastProfiles)
val app = requireActivity().application as BasedBankApp
app.accounts = accounts
app.mibSession = flow.lastSession

View File

@@ -17,8 +17,10 @@ object AccountCache {
val arr = JSONArray()
for (acc in accounts) {
arr.put(JSONObject().apply {
put("bank", acc.bank)
put("profileName", acc.profileName)
put("profileType", acc.profileType)
put("cifType", acc.cifType)
put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName)
@@ -68,20 +70,21 @@ object AccountCache {
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
bank = "BML",
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = null,
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
)
}
} catch (_: Exception) { emptyList() }
@@ -121,20 +124,21 @@ object AccountCache {
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
bank = "FAHIPAY",
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = null,
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
)
}
} catch (_: Exception) { emptyList() }
@@ -153,20 +157,22 @@ object AccountCache {
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
bank = o.optString("bank", "MIB"),
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
cifType = o.optString("cifType", ""),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "")
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "")
)
}
} catch (e: Exception) {

View File

@@ -0,0 +1,15 @@
package sh.sar.basedbank.util
/**
* Standard display model for the account history header card — produced by
* per-bank parsers, consumed by AccountHistoryAdapter with no bank logic.
*/
data class AccountHistoryDisplay(
val name: String,
val number: String,
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
val availableBalance: String, // formatted "CCY amount"
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
val blockedBalance: String? // null if zero or not applicable
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
import sh.sar.basedbank.util.mibapi.MibHistoryParser
object AccountHistoryParser {
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) {
"BML" -> BmlHistoryParser.displayData(account)
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
"MIB" -> MibHistoryParser.displayData(account)
else -> null
}
}

View File

@@ -0,0 +1,11 @@
package sh.sar.basedbank.util
data class AccountListDisplay(
val name: String,
val number: String,
val typeLabel: String,
val balance: String,
val isCard: Boolean = false,
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
val statusLabel: String? = null // null = active; shown as status pill if set
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
import sh.sar.basedbank.util.mibapi.MibAccountParser
object AccountListParser {
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
"BML" -> BmlDashboardParser.displayData(account)
"FAHIPAY" -> FahipayAccountParser.displayData(account)
"MIB" -> MibAccountParser.displayData(account)
else -> null
}
}

View File

@@ -1,28 +0,0 @@
package sh.sar.basedbank.util
object BmlDashboardParser {
/**
* Returns a display-ready product label for a BML dashboard account or card.
* Known BML product names are mapped to short friendly labels.
* Everything else is title-cased (first letter of each word capitalised).
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> toTitleCase(raw)
}
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View File

@@ -0,0 +1,22 @@
package sh.sar.basedbank.util
/**
* Standard display model for a contact row — produced by per-bank parsers,
* consumed by UI. No bank-specific logic in adapters or fragments.
*/
data class ContactDisplay(
val id: String, // internal contact ID (benefNo)
val name: String, // display nickname
val realName: String, // legal name — used for search
val accountNumber: String,
val categoryId: String, // for tab filtering
val network: TransferNetwork,
val bankColor: String,
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
val imageHash: String?,
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
val canTransfer: Boolean,
val canEdit: Boolean,
val canDelete: Boolean
)

View File

@@ -0,0 +1,18 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.bmlapi.BmlContactParser
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
import sh.sar.basedbank.util.mibapi.MibContactParser
object ContactListParser {
fun from(contact: MibBeneficiary): ContactDisplay? = when {
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
else -> null
}
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
}

View File

@@ -0,0 +1,37 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibContactsClient
/**
* Behaviour dispatcher for contact operations.
* Routes add/delete to the correct bank API based on TransferNetwork.
* UI code never inspects the network or bank type directly.
*/
object ContactManager {
/** Deletes [contact] via the appropriate bank API. Returns true on success. */
suspend fun delete(contact: ContactDisplay, app: BasedBankApp): Boolean = when (contact.network) {
TransferNetwork.BML -> deleteBml(contact, app)
TransferNetwork.FAHIPAY -> false // Fahipay contacts are read-only
TransferNetwork.MIB, TransferNetwork.LOCAL, TransferNetwork.SWIFT -> deleteMib(contact, app)
}
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.id.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.mibSession ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.id)
} catch (_: Exception) { false }
}
}

View File

@@ -329,6 +329,43 @@ class CredentialStore(context: Context) {
val birthdate: String
)
// ── MIB operating profiles (all profiles, regardless of visibility) ───────
fun saveMibProfiles(profiles: List<sh.sar.basedbank.api.mib.MibProfile>) {
val arr = org.json.JSONArray()
for (p in profiles) {
arr.put(org.json.JSONObject().apply {
put("profileId", p.profileId)
put("name", p.name)
put("cifType", p.cifType)
put("profileType", p.profileType)
put("color", p.color)
})
}
prefs.edit().putString("mib_all_profiles", arr.toString()).apply()
}
fun loadMibProfiles(): List<sh.sar.basedbank.api.mib.MibProfile> {
val raw = prefs.getString("mib_all_profiles", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
sh.sar.basedbank.api.mib.MibProfile(
profileId = o.optString("profileId"),
customerProfileId = o.optString("profileId"),
annexId = "",
customerId = "",
name = o.optString("name"),
cifType = o.optString("cifType"),
profileType = o.optString("profileType"),
color = o.optString("color"),
customerImage = null
)
}
} catch (_: Exception) { emptyList() }
}
fun saveMibFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
@@ -399,6 +436,15 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null }
}
// ── MIB profile visibility ────────────────────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide from the app. */
fun getHiddenMibProfileIds(): Set<String> =
prefs.getStringSet("mib_hidden_profile_ids", emptySet()) ?: emptySet()
fun setHiddenMibProfileIds(ids: Set<String>) =
prefs.edit().putStringSet("mib_hidden_profile_ids", ids).apply()
// ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey {

View File

@@ -0,0 +1,115 @@
package sh.sar.basedbank.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/**
* Encapsulates all bank-specific pagination state and fetch logic for account history.
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
* without knowing which bank it is talking to.
*/
class HistoryFetcher(private val account: MibAccount) {
private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private val isFahipay get() = account.bank == "FAHIPAY"
// MIB pagination
private var mibNextStart = 1
private var mibTotalCount = -1
// BML CASA pagination
private var bmlNextPage = 1
private var bmlTotalPages = -1
// BML card pagination (month-based)
private var cardMonthOffset = 0
// Fahipay pagination
private var fahipayNextStart = 0
private var fahipayTotal = -1
fun hasMore(): Boolean = when {
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
}
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
val session = app.fahipaySession ?: return emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
return list
}
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
val session = app.mibSession ?: return emptyList()
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
return list
}
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
return BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return 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++
return list
}
}

View File

@@ -1,18 +0,0 @@
package sh.sar.basedbank.util
object MibAccountParser {
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
* Known MIB accountTypeName values are mapped to short friendly labels.
* Everything else is returned trimmed as-is.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

View File

@@ -0,0 +1,10 @@
package sh.sar.basedbank.util
/** App-unified term for the transfer routing network a contact uses. */
enum class TransferNetwork {
MIB, // MIB internal (both parties on MIB)
LOCAL, // local inter-bank via IPS (e.g. BML from MIB's side)
SWIFT, // international SWIFT
BML, // Bank of Maldives
FAHIPAY // Fahipay wallet
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object BmlContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.BML,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}

View File

@@ -0,0 +1,69 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
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: MibAccount): AccountListDisplay {
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}",
isCard = true,
cardBrandIcon = cardBrandIcon(account.accountTypeName),
statusLabel = if (isActive) null else account.statusDesc
)
} else {
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = listBalance(account)
)
}
}
/**
* Returns a display-ready product label for a BML dashboard account or card.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> toTitleCase(raw)
}
}
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
fun listBalance(account: MibAccount): String =
"${account.currencyName} ${account.currentBalance}"
fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> R.drawable.ic_nav_card
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View File

@@ -0,0 +1,20 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object BmlHistoryParser {
fun displayData(account: MibAccount): AccountHistoryDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "BML",
typeLabel = BmlDashboardParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
}

View File

@@ -0,0 +1,14 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object FahipayAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = account.accountTypeName,
balance = "${account.currencyName} ${account.availableBalance}"
)
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object FahipayContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.FAHIPAY,
bankColor = contact.bankColor,
detail = null, // Fahipay contacts show no detail line
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
canTransfer = false,
canEdit = false,
canDelete = false
)
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object FahipayHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "FP",
typeLabel = account.accountTypeName,
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

View File

@@ -0,0 +1,26 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}"
)
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

View File

@@ -0,0 +1,32 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object MibContactParser {
fun displayData(contact: MibBeneficiary): ContactDisplay {
val network = when (contact.benefType) {
"I" -> TransferNetwork.MIB
"S" -> TransferNetwork.SWIFT
else -> TransferNetwork.LOCAL // "L" and anything else
}
return ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = network,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object MibHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

View File

@@ -37,9 +37,17 @@
android:fontFamily="monospace"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvAccountType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Right: segmented pill (bank | type) + balance -->
<!-- Right: balance + transfer button -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -47,31 +55,22 @@
android:gravity="end"
android:layout_marginStart="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/pill_segment_bg">
<TextView
android:id="@+id/tvPillType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<TextView
android:id="@+id/tvBalance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" />
android:textColor="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout>

View File

@@ -56,7 +56,7 @@
</LinearLayout>
<!-- Status pill + balance (prepaid only) -->
<!-- Transfer button + balance -->
<LinearLayout
android:id="@+id/layoutCardBalance"
android:layout_width="wrap_content"
@@ -85,6 +85,16 @@
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout>
</LinearLayout>

View File

@@ -150,6 +150,7 @@
<string name="login_detail_id_card">ID Card</string>
<string name="login_detail_profiles">Profiles</string>
<string name="close">Close</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<!-- Home -->

85
docs/PARSERS.md Normal file
View File

@@ -0,0 +1,85 @@
# Account Display Parser Architecture
## Overview
Each bank's API returns account data in different formats and uses different field names for balances, product types, and status. To keep screens bank-agnostic, each bank has a dedicated parser that translates raw `MibAccount` model data into a standard `AccountListDisplay` object. Screens consume only `AccountListDisplay` — they never inspect `bank` or `profileType` or apply bank-specific logic.
## Bank Discriminator — `MibAccount.bank`
All dispatchers route by `account.bank`, a string set explicitly by each login flow at account creation time:
| `bank` value | Set by |
|--------------|-------------------|
| `"MIB"` | `MibLoginFlow` |
| `"BML"` | `BmlLoginFlow` |
| `"FAHIPAY"` | `FahipayLoginFlow`|
`profileType` is a bank-internal value (e.g. MIB's numeric profile ID, or BML's `"BML_PREPAID"`) and is **never** used for bank routing. Card-type checks within BML still use `profileType` (`"BML_PREPAID"` / `"BML_CREDIT"`).
`cifType` (MIB only) is the human-readable profile category name returned by the `operatingProfiles` API (e.g. `"Individual"`, `"Sole Propr"`). It is stored on `MibAccount` and surfaced in the accounts list section header and settings. It is **never hardcoded** in the app.
## Standard Output Model
```kotlin
// util/AccountListDisplay.kt
data class AccountListDisplay(
val name: String, // account or card display name
val number: String, // account/card number
val typeLabel: String, // human-friendly product type (e.g. "Savings", "Visa Platinum")
val balance: String, // formatted balance string (e.g. "MVR 1,234.56")
val isCard: Boolean, // true → use card layout; false → use account layout
val cardBrandIcon: Int, // drawable res for card brand logo (Visa / Mastercard / Amex)
val statusLabel: String? // null = active; non-null = shown as status label (e.g. "Inactive")
)
```
## Dispatcher
```kotlin
// util/AccountListParser.kt
AccountListParser.from(account: MibAccount): AccountListDisplay?
```
Routes to the correct parser based on `account.bank`. Returns `null` for unknown banks — never falls back to a specific bank.
| `account.bank` | Parser |
|----------------|-------------------------|
| `"BML"` | `BmlDashboardParser` |
| `"FAHIPAY"` | `FahipayAccountParser` |
| `"MIB"` | `MibAccountParser` |
| anything else | `null` |
## Bank Parsers
### BML — `util/bmlapi/BmlDashboardParser`
Handles both CASA accounts and prepaid/credit cards.
- **CASA balance**: uses `ledgerBalance` (working balance) — mapped to `account.currentBalance`
- **Card balance**: uses `availableBalance` (available limit from `cardBalance.AvailableLimit`)
- **Card brand**: resolved from product name (`VISA` / `MASTERCARD` / `AMEX`)
- **Status**: cards with `statusDesc != "Active"` surface `statusLabel`; active cards return `null`
### MIB — `util/mibapi/MibAccountParser`
- **Balance**: `availableBalance` from the MIB API directly
- Known product names (`SAVING ACCOUNT`, `CURRENT ACCOUNT`) mapped to short labels
- `cifType` (e.g. `"Individual"`, `"Sole Propr"`) comes from `MibProfile.cifType`, stored on `MibAccount`, displayed in section headers
### Fahipay — `util/fahipayapi/FahipayAccountParser`
- Single wallet account per user; `accountTypeName` is always `"Digital Wallet"`
- **Balance**: `availableBalance`
## Adding a New Bank
1. Create `util/<bankname>api/<Bank>AccountParser.kt` with a `displayData(account: MibAccount): AccountListDisplay` function
2. Set `bank = "<BANKNAME>"` in the new login flow when creating `MibAccount` objects
3. Add a `when` branch in `AccountListParser.from()` (and other dispatchers) for the new bank value
4. No changes needed in any screen or adapter
## Usage in Screens
`AccountsAdapter` calls `AccountListParser.from(account)` once per item (skipping `null` results) and binds the resulting `AccountListDisplay` directly. The adapter has zero bank-specific logic.
The transfer screen dropdown (`TransferFragment`) also uses `AccountListParser.from(acc)?.balance` for the source account balance display.