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

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
+3 -3
View File
@@ -3,11 +3,11 @@
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DIALOG" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z"> <DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" /> <DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>
@@ -618,6 +618,7 @@ class BmlLoginFlow {
if (accountType == "CASA") { if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0) val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount( casaAccounts.add(MibAccount(
bank = "BML",
profileName = "Personal", profileName = "Personal",
profileType = "BML", profileType = "BML",
accountNumber = accountNumber, accountNumber = accountNumber,
@@ -641,6 +642,7 @@ class BmlLoginFlow {
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount( prepaidCards.add(MibAccount(
bank = "BML",
profileName = "Personal", profileName = "Personal",
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT", profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber, accountNumber = accountNumber,
@@ -186,6 +186,7 @@ class FahipayLoginFlow {
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount = fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
MibAccount( MibAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" }, profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY", profileType = "FAHIPAY",
accountNumber = profile.walletAccount, accountNumber = profile.walletAccount,
@@ -136,10 +136,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
val profiles = parseProfiles(loginResp) val profiles = parseProfiles(loginResp)
lastSession = session2 lastSession = session2
lastProfiles = profiles lastProfiles = profiles // keep ALL profiles so settings can show them all
return fetchAllProfiles(session2, profiles, "mib_$username")
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 ───────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────
@@ -271,8 +313,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val a = accountBalances.getJSONObject(i) val a = accountBalances.getJSONObject(i)
allAccounts.add( allAccounts.add(
MibAccount( MibAccount(
bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
@@ -20,8 +20,10 @@ data class MibProfile(
) )
data class MibAccount( data class MibAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String, val profileName: String,
val profileType: 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 accountNumber: String,
val accountBriefName: String, val accountBriefName: String,
val currencyName: String, val currencyName: String,
@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
@@ -20,7 +21,8 @@ import java.util.Date
import java.util.Locale import java.util.Locale
class AccountHistoryAdapter( class AccountHistoryAdapter(
private val account: MibAccount private val account: MibAccount,
private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
@@ -138,7 +140,7 @@ class AccountHistoryAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) { when (holder) {
is HeaderVH -> holder.bind(account) is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label) is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction) is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit else -> Unit
@@ -147,37 +149,20 @@ class AccountHistoryAdapter(
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) : inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
RecyclerView.ViewHolder(b.root) { RecyclerView.ViewHolder(b.root) {
fun bind(acc: MibAccount) { fun bind(d: AccountHistoryDisplay) {
b.tvHeaderAccountName.text = acc.accountBriefName b.tvHeaderAccountName.text = d.name
b.tvHeaderAccountNumber.text = acc.accountNumber b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = when { b.tvHeaderPillBank.text = d.bankPill
acc.profileType.startsWith("BML") -> "BML" b.tvHeaderPillType.text = d.typeLabel
acc.profileType == "FAHIPAY" -> "FP" b.tvHeaderAvailable.text = d.availableBalance
else -> null b.tvHeaderBalance.text = d.workingBalance
} if (d.blockedBalance != null) {
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName) b.tvHeaderBlocked.text = d.blockedBalance
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
if (blocked > 0.0) {
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
b.llHeaderBlocked.visibility = View.VISIBLE b.llHeaderBlocked.visibility = View.VISIBLE
} else { } else {
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
} }
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) } b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
}
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)
}
} }
} }
@@ -17,23 +17,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R 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.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient 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.Transaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class AccountHistoryFragment : Fragment() { class AccountHistoryFragment : Fragment() {
@@ -43,21 +38,13 @@ class AccountHistoryFragment : Fragment() {
private lateinit var adapter: AccountHistoryAdapter private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount private lateinit var account: MibAccount
private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = "" private var searchQuery = ""
private var firstPageDone = false private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>() private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = 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 var isLoading = false
private val pageSize = 10 private val pageSize = 10
@@ -79,8 +66,10 @@ class AccountHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: 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.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc -> adapter.onTransferClick = { acc ->
@@ -104,11 +93,10 @@ class AccountHistoryFragment : Fragment() {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: "" searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay() 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) val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
allTransactions.addAll(cached) allTransactions.addAll(cached)
@@ -133,19 +121,8 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
} }
private fun 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() { private fun loadNextPage() {
if (isLoading || !hasMore()) return if (isLoading || !fetcher.hasMore()) return
isLoading = true isLoading = true
if (firstPageDone && allTransactions.isNotEmpty()) { if (firstPageDone && allTransactions.isNotEmpty()) {
@@ -155,68 +132,7 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) { val transactions = fetcher.fetchNextPage(app, pageSize)
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
}
}
}
isLoading = false isLoading = false
@@ -233,7 +149,6 @@ class AccountHistoryFragment : Fragment() {
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions) TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
if (searchQuery.isBlank()) { if (searchQuery.isBlank()) {
// Append incrementally to preserve scroll position
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
adapter.appendTransactions(sorted) adapter.appendTransactions(sorted)
binding.emptyView.visibility = View.GONE binding.emptyView.visibility = View.GONE
@@ -243,7 +158,7 @@ class AccountHistoryFragment : Fragment() {
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
} }
if (searchQuery.isNotBlank() && hasMore()) loadNextPage() if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE 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 response = client.newCall(Request.Builder().url(url).build()).execute()
val bytes = response.body?.bytes() ?: return@launch val bytes = response.body?.bytes() ?: return@launch
response.close() response.close()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
?: return@launch
MerchantIconCache.save(requireContext(), url, bitmap) MerchantIconCache.save(requireContext(), url, bitmap)
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) } withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
} catch (_: Exception) { } catch (_: Exception) {
@@ -8,23 +8,24 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.BmlDashboardParser import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.MibAccountParser import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter( class AccountsAdapter(
accounts: List<MibAccount>, accounts: List<MibAccount>,
private val onAccountClick: (MibAccount) -> Unit = {} private val onAccountClick: (MibAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((MibAccount) -> Unit)? = null
private sealed class Item { private sealed class Item {
data class SectionTitle(val label: String) : Item() data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item() data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount) : Item() data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
} }
private val items: MutableList<Item> = buildItems(accounts).toMutableList() private val items: MutableList<Item> = buildItems(accounts).toMutableList()
@@ -36,38 +37,38 @@ class AccountsAdapter(
} }
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList { private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" } val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } 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<Pair<MibAccount, AccountListDisplay>>>()
val groups = LinkedHashMap<String, MutableList<MibAccount>>() for ((acc, display) in nonCards) {
for (acc in nonPrepaid) {
val title = sectionTitle(acc) val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc) groups.getOrPut(title) { mutableListOf() }.add(acc to display)
} }
for ((title, group) in groups) { for ((title, group) in groups) {
add(Item.SectionTitle(title)) 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")) 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 { private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) { val bankName = when (account.bank) {
"0" -> "Personal" "BML" -> "Bank of Maldives"
"1" -> "Business" "FAHIPAY" -> "Fahipay"
else -> account.profileName "MIB" -> "Maldives Islamic Bank"
else -> account.bank
} }
val bank = when { val profileLabel = when (account.bank) {
account.profileType.startsWith("BML") -> "Bank of Maldives" "MIB" -> account.cifType.ifBlank { account.profileName }
account.profileType == "FAHIPAY" -> "Fahipay" else -> account.profileName
else -> "Maldives Islamic Bank"
} }
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
} }
override fun getItemViewType(position: Int) = when (items[position]) { override fun getItemViewType(position: Int) = when (items[position]) {
@@ -79,17 +80,17 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
return when (viewType) { return when (viewType) {
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false)) TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false)) TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false)) else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
} }
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) { when (val item = items[position]) {
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item) is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
is Item.Account -> (holder as AccountViewHolder).bind(item.account) is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
is Item.Card -> (holder as CardViewHolder).bind(item.account) 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) : private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: MibAccount, display: AccountListDisplay) {
binding.tvAccountName.text = account.accountBriefName binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = account.accountNumber binding.tvAccountNumber.text = display.number
val label = if (account.profileType.startsWith("BML")) binding.tvAccountType.text = display.typeLabel
BmlDashboardParser.productLabel(account.accountTypeName) binding.tvBalance.text = display.balance
else binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
MibAccountParser.productLabel(account.accountTypeName)
binding.tvPillType.text = label
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber) copyToClipboard(it.context, display.number)
true true
} }
} }
@@ -123,23 +121,22 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) : private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: MibAccount, display: AccountListDisplay) {
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName)) binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardName.text = account.accountBriefName binding.tvCardName.text = display.name
binding.tvCardNumber.text = account.accountNumber binding.tvCardNumber.text = display.number
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName) binding.tvCardProduct.text = display.typeLabel
binding.layoutCardBalance.visibility = View.VISIBLE binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}" binding.tvCardBalance.text = display.balance
if (display.statusLabel != null) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) binding.tvCardStatus.text = display.statusLabel
if (isActive) {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} else {
binding.tvCardStatus.text = account.statusDesc
binding.tvCardStatus.visibility = View.VISIBLE binding.tvCardStatus.visibility = View.VISIBLE
binding.root.alpha = 0.45f 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) } binding.root.setOnClickListener { onAccountClick(account) }
} }
} }
@@ -154,13 +151,5 @@ class AccountsAdapter(
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber)) cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show() 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
}
} }
} }
@@ -26,6 +26,9 @@ class AccountsFragment : Fragment() {
adapter = AccountsAdapter(emptyList()) { account -> adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(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.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
@@ -92,7 +92,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun buildDestinations(): List<DestinationOption> { private fun buildDestinations(): List<DestinationOption> {
val list = mutableListOf<DestinationOption>() val list = mutableListOf<DestinationOption>()
for (profile in app.mibProfiles) { 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()) val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) { for ((loginId, _) in app.bmlSessions) {
@@ -12,23 +12,23 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter( class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>, private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit, private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit, private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList() private var displayed: List<ContactDisplay> = emptyList()
private var activeCategoryId: String? = null private var activeCategoryId: String? = null
private var searchQuery: String = "" private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) { fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts allContacts = contacts
applyFilter() applyFilter()
} }
@@ -36,7 +36,7 @@ class ContactsAdapter(
fun updateImage(hash: String, bitmap: Bitmap) { fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact -> 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() { private fun applyFilter() {
displayed = allContacts.filter { contact -> displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() || val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) || contact.name.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) || contact.realName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery) contact.accountNumber.contains(searchQuery)
matchesCategory && matchesSearch matchesCategory && matchesSearch
} }
notifyDataSetChanged() notifyDataSetChanged()
@@ -76,7 +76,7 @@ class ContactsAdapter(
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false 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 val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account)) clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show() Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
@@ -88,7 +88,7 @@ class ContactsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position] val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash -> val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null } imageCache[hash] ?: run { onImageNeeded(hash); null }
} }
holder.bind(contact, cachedImage) holder.bind(contact, cachedImage)
@@ -99,21 +99,24 @@ class ContactsAdapter(
inner class ViewHolder(val binding: ItemContactBinding) : inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) { fun bind(contact: ContactDisplay, photo: Bitmap?) {
val isFahipay = contact.benefType == "FAHIPAY" binding.tvContactName.text = contact.name
binding.tvContactName.text = contact.benefNickName binding.tvContactAccount.text = contact.accountNumber
binding.tvContactAccount.text = contact.benefAccount binding.tvRealName.text = contact.detail ?: ""
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}" binding.tvRealName.visibility =
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE binding.btnTransferContact.visibility =
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE 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) { if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo) binding.ivContactPhoto.setImageBitmap(photo)
} else { } else {
binding.ivContactPhoto.setImageBitmap( binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor) makeInitialsBitmap(contact.name, contact.bankColor)
) )
} }
} }
@@ -21,13 +21,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache 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.ContactsCache
import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() { class ContactsFragment : Fragment() {
@@ -40,7 +42,7 @@ class ContactsFragment : Fragment() {
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.mibSession
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var currentSearch: String = "" private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter private lateinit var pagerAdapter: ContactsPagerAdapter
@@ -53,9 +55,9 @@ class ContactsFragment : Fragment() {
private val density get() = resources.displayMetrics.density private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page -> val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter( ContactsAdapter(
imageCache = sharedImageCache, imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) }, onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) }, onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) } onTransferClick = { contact -> openTransfer(contact) }
).also { a -> ).also { a ->
a.setFilter(page.categoryId, currentSearch) 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) } contactAdapters.forEach { it.updateContacts(contacts) }
fun updateSearch(query: String) = fun updateSearch(query: String) =
@@ -124,8 +126,8 @@ class ContactsFragment : Fragment() {
} }
viewModel.contacts.observe(viewLifecycleOwner) { contacts -> viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
allContacts = contacts allContacts = ContactListParser.fromList(contacts)
pagerAdapter.updateContacts(contacts) pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
} }
@@ -150,31 +152,29 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false) binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
} }
private fun openTransfer(contact: MibBeneficiary) { private fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance( val fragment = TransferFragment.newInstance(
accountNumber = contact.benefAccount, accountNumber = contact.accountNumber,
displayName = contact.benefNickName, displayName = contact.name,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}", subtitle = contact.transferSubtitle,
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.customerImgHash imageHash = contact.imageHash
) )
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
} }
private fun confirmDelete(contact: MibBeneficiary) { private fun confirmDelete(contact: ContactDisplay) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setTitle(R.string.contact_delete_title) .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) } .setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
private fun deleteContact(contact: MibBeneficiary) { private fun deleteContact(contact: ContactDisplay) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
}
if (success) { if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact) removeFromViewModel(contact)
@@ -184,27 +184,10 @@ class ContactsFragment : Fragment() {
} }
} }
private fun deleteBml(contact: MibBeneficiary): Boolean { private fun removeFromViewModel(contact: ContactDisplay) {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
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
viewModel.contacts.value = updated viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") { if (contact.network == TransferNetwork.BML) {
updated.filter { it.benefCategoryId == "BML" } updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId } .groupBy { it.profileId }
.forEach { (loginId, contacts) -> .forEach { (loginId, contacts) ->
@@ -221,7 +204,6 @@ class ContactsFragment : Fragment() {
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return if (!pendingHashes.add(hash)) return
// Check disk cache first — if hash matches we already have the image
val cached = ContactImageCache.load(requireContext(), hash) val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) { if (cached != null) {
view?.post { pagerAdapter.updateImage(hash, cached) } view?.post { pagerAdapter.updateImage(hash, cached) }
@@ -130,9 +130,9 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp val app = application as BasedBankApp
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) { if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background // 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 val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
viewModel.accounts.value = merged viewModel.accounts.value = merged.filterVisibleAccounts()
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts) if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
if (app.bmlAccounts.isNotEmpty()) { if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") } val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
@@ -145,7 +145,7 @@ class HomeActivity : AppCompatActivity() {
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits 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) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
} else { } else {
// Came from lock screen — show caches immediately, refresh everything in background // 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 // Immediately drop accounts for logged-out banks from the displayed list
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
viewModel.accounts.value = current.filter { acc -> viewModel.accounts.value = current.filter { acc ->
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false if (!hasMib && acc.bank == "MIB") return@filter false
if (acc.profileType.startsWith("BML")) { if (acc.bank == "BML") {
val loginId = acc.loginTag.removePrefix("bml_") val loginId = acc.loginTag.removePrefix("bml_")
return@filter loginId in bmlLoginIds return@filter loginId in bmlLoginIds
} }
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false if (!hasFahipay && acc.bank == "FAHIPAY") return@filter false
true true
} }
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store) autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
@@ -395,6 +395,7 @@ class HomeActivity : AppCompatActivity() {
app.mibSession = flow.lastSession app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles app.mibProfiles = flow.lastProfiles
AccountCache.save(this@HomeActivity, accounts) AccountCache.save(this@HomeActivity, accounts)
CredentialStore(this@HomeActivity).saveMibProfiles(flow.lastProfiles)
accounts accounts
} catch (_: Exception) { AccountCache.load(this@HomeActivity) } } catch (_: Exception) { AccountCache.load(this@HomeActivity) }
} }
@@ -488,14 +489,34 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp val app = application as BasedBankApp
app.bmlAccounts = bmlAccounts app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts) viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE binding.refreshIndicator.visibility = View.GONE
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) 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) { private fun refreshBmlLimits(session: BmlSession) {
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
lifecycleScope.launch { lifecycleScope.launch {
@@ -550,7 +571,7 @@ class HomeActivity : AppCompatActivity() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
} }
// Refresh all banks in background // Refresh all banks in background
refreshContacts(app.mibSession, app.mibProfiles) refreshContacts(app.mibSession, app.mibProfiles.filterVisibleProfiles())
refreshBmlContacts(app) refreshBmlContacts(app)
if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!) if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!)
} }
@@ -630,7 +651,7 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp val app = application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
if (src.profileType == "FAHIPAY") { if (src.bank == "FAHIPAY") {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.fahipaySession ?: return@withContext null val sess = app.fahipaySession ?: return@withContext null
try { try {
@@ -645,9 +666,9 @@ class HomeActivity : AppCompatActivity() {
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val others = current.filter { it.profileType != "FAHIPAY" } val others = current.filter { it.bank != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh) viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) { } else if (src.bank == "BML") {
val loginId = src.loginTag.removePrefix("bml_") val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null val sess = app.bmlSessionFor(src) ?: return@withContext null
@@ -659,20 +680,36 @@ class HomeActivity : AppCompatActivity() {
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: 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) viewModel.accounts.postValue(otherAccounts + fresh)
} else { } else {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null val store = CredentialStore(this@HomeActivity)
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null val hidden = store.getHiddenMibProfileIds()
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) } val allVisible = app.mibProfiles.filter { hidden.isEmpty() || it.profileId !in hidden }
catch (_: Exception) { null } // 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 } ?: return@launch
// Replace accounts from this profile only, keep everything else // Replace all MIB accounts with fresh data
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") } val others = current.filter { it.bank != "MIB" }
val merged = others + fresh AccountCache.save(this@HomeActivity, fresh)
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") }) viewModel.accounts.postValue(others + fresh)
viewModel.accounts.postValue(merged)
} }
} }
} }
@@ -7,6 +7,7 @@ import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@@ -14,6 +15,7 @@ import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
@@ -67,22 +69,9 @@ class SettingsLoginsFragment : Fragment() {
if (hasMib) { if (hasMib) {
val profile = store.loadMibUserProfile() val profile = store.loadMibUserProfile()
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name) 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) { addLoginRow(container, R.drawable.mib_logo, displayName) {
showLoginDetails( showMibLoginDetails(store, profile, mibProfiles)
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) } }
)
} }
} }
@@ -154,6 +143,117 @@ class SettingsLoginsFragment : Fragment() {
container.addView(row) 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) { private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(title) .setTitle(title)
@@ -49,6 +49,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
@@ -209,16 +210,15 @@ class TransferFragment : Fragment() {
} }
private fun showFromCard(account: MibAccount) { private fun showFromCard(account: MibAccount) {
val isBml = account.profileType.startsWith("BML") val colorHex = when (account.bank) {
val colorHex = when { "BML" -> "#0066A1"
isBml -> "#0066A1" "FAHIPAY" -> "#15BEA7"
account.profileType == "FAHIPAY" -> "#15BEA7" else -> "#FE860E"
else -> "#FE860E"
} }
val bankLabel = when { val bankLabel = when (account.bank) {
isBml -> "BML" "BML" -> "BML"
account.profileType == "FAHIPAY" -> "FP" "FAHIPAY" -> "FP"
else -> null else -> null
} }
val typeLabel = when { val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card" account.profileType == "BML_PREPAID" -> "Prepaid Card"
@@ -234,7 +234,7 @@ class TransferFragment : Fragment() {
binding.tilFrom.visibility = View.GONE binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE binding.cardFromInfo.visibility = View.VISIBLE
if (!isBml && account.profileImageHash != null) { if (account.bank != "BML" && account.profileImageHash != null) {
loadFromPhoto(account.profileImageHash) loadFromPhoto(account.profileImageHash)
} }
} }
@@ -312,7 +312,7 @@ class TransferFragment : Fragment() {
} }
// Fahipay source: only phone numbers are supported // Fahipay source: only phone numbers are supported
if (selectedAccount?.profileType == "FAHIPAY") { if (selectedAccount?.bank == "FAHIPAY") {
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) { if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupFahipayTarget(accountNumber) lookupFahipayTarget(accountNumber)
} else { } else {
@@ -328,7 +328,7 @@ class TransferFragment : Fragment() {
return return
} }
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true val isBmlSource = selectedAccount?.bank == "BML"
startLookupLoading() startLookupLoading()
@@ -567,7 +567,7 @@ class TransferFragment : Fragment() {
binding.tilAmount.error = null binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" 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 isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" } val currency = src.currencyName.ifBlank { "MVR" }
@@ -1009,11 +1009,11 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true) 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 "" val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}" b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber 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.alpha = if (inactive) 0.4f else 1f
b.root b.root
} }
@@ -61,10 +61,10 @@ class TransferHistoryFragment : Fragment() {
var fahipayTotal: Int = -1 var fahipayTotal: Int = -1
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
} }
@@ -149,7 +149,7 @@ class TransferHistoryFragment : Fragment() {
val results = mutableListOf<Transaction>() val results = mutableListOf<Transaction>()
// BML accounts: fetch in parallel // 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 -> results.addAll(bmlStates.map { state ->
async { async {
try { try {
@@ -187,7 +187,7 @@ class TransferHistoryFragment : Fragment() {
}.awaitAll().flatten()) }.awaitAll().flatten())
// Fahipay accounts // Fahipay accounts
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" } val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
for (state in fahipayStates) { for (state in fahipayStates) {
val session = app.fahipaySession ?: continue val session = app.fahipaySession ?: continue
try { try {
@@ -206,9 +206,7 @@ class TransferHistoryFragment : Fragment() {
} }
// MIB accounts: serialized per profile, protected by mutex to prevent session race // MIB accounts: serialized per profile, protected by mutex to prevent session race
val mibStates = activeStates.filter { val mibStates = activeStates.filter { it.account.bank == "MIB" }
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY"
}
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) { for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break val session = mibSession ?: break
app.mibMutex.withLock { app.mibMutex.withLock {
@@ -194,6 +194,7 @@ class CredentialsFragment : Fragment() {
} }
} }
AccountCache.save(requireContext(), accounts) AccountCache.save(requireContext(), accounts)
CredentialStore(requireContext()).saveMibProfiles(flow.lastProfiles)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.accounts = accounts app.accounts = accounts
app.mibSession = flow.lastSession app.mibSession = flow.lastSession
@@ -17,8 +17,10 @@ object AccountCache {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
put("bank", acc.bank)
put("profileName", acc.profileName) put("profileName", acc.profileName)
put("profileType", acc.profileType) put("profileType", acc.profileType)
put("cifType", acc.cifType)
put("accountNumber", acc.accountNumber) put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName) put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName) put("currencyName", acc.currencyName)
@@ -68,20 +70,21 @@ object AccountCache {
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = "BML",
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
@@ -121,20 +124,21 @@ object AccountCache {
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = "FAHIPAY",
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
@@ -153,20 +157,22 @@ object AccountCache {
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = o.optString("bank", "MIB"),
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
cifType = o.optString("cifType", ""),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() }, profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "") profileId = o.optString("profileId", "")
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -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
)
@@ -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
}
}
@@ -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
)
@@ -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
}
}
@@ -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() }
}
}
@@ -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
)
@@ -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) }
}
@@ -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 }
}
}
@@ -329,6 +329,43 @@ class CredentialStore(context: Context) {
val birthdate: String 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) { fun saveMibFullName(name: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply() prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
@@ -399,6 +436,15 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } 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 ───────────────────────────────────────────────────── // ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey { private fun getOrCreateKey(): SecretKey {
@@ -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
}
}
@@ -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()
}
}
}
@@ -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
}
@@ -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
)
}
@@ -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() }
}
}
@@ -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
)
}
}
@@ -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}"
)
}
@@ -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
)
}
@@ -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
)
}
@@ -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()
}
}
}
@@ -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
)
}
}
@@ -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
)
}
+20 -21
View File
@@ -37,9 +37,17 @@
android:fontFamily="monospace" android:fontFamily="monospace"
android:layout_marginTop="2dp" /> 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> </LinearLayout>
<!-- Right: segmented pill (bank | type) + balance --> <!-- Right: balance + transfer button -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -47,31 +55,22 @@
android:gravity="end" android:gravity="end"
android:layout_marginStart="16dp"> 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 <TextView
android:id="@+id/tvBalance" android:id="@+id/tvBalance"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall" android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" 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>
+11 -1
View File
@@ -56,7 +56,7 @@
</LinearLayout> </LinearLayout>
<!-- Status pill + balance (prepaid only) --> <!-- Transfer button + balance -->
<LinearLayout <LinearLayout
android:id="@+id/layoutCardBalance" android:id="@+id/layoutCardBalance"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -85,6 +85,16 @@
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" /> 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>
</LinearLayout> </LinearLayout>
+1
View File
@@ -150,6 +150,7 @@
<string name="login_detail_id_card">ID Card</string> <string name="login_detail_id_card">ID Card</string>
<string name="login_detail_profiles">Profiles</string> <string name="login_detail_profiles">Profiles</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<!-- Home --> <!-- Home -->
+85
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.