refactor codebase to be more module for later adding new banks.. add support for single profile mib accounts.. add suport for disabling mib profiles in settings
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
This commit is contained in:
6
.idea/deploymentTargetSelector.xml
generated
6
.idea/deploymentTargetSelector.xml
generated
@@ -3,11 +3,11 @@
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DIALOG" />
|
||||
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -618,6 +618,7 @@ class BmlLoginFlow {
|
||||
if (accountType == "CASA") {
|
||||
val available = item.optDouble("availableBalance", 0.0)
|
||||
casaAccounts.add(MibAccount(
|
||||
bank = "BML",
|
||||
profileName = "Personal",
|
||||
profileType = "BML",
|
||||
accountNumber = accountNumber,
|
||||
@@ -641,6 +642,7 @@ class BmlLoginFlow {
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
prepaidCards.add(MibAccount(
|
||||
bank = "BML",
|
||||
profileName = "Personal",
|
||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||
accountNumber = accountNumber,
|
||||
|
||||
@@ -186,6 +186,7 @@ class FahipayLoginFlow {
|
||||
|
||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
|
||||
MibAccount(
|
||||
bank = "FAHIPAY",
|
||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||
profileType = "FAHIPAY",
|
||||
accountNumber = profile.walletAccount,
|
||||
|
||||
@@ -136,10 +136,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
|
||||
val profiles = parseProfiles(loginResp)
|
||||
|
||||
lastSession = session2
|
||||
lastProfiles = profiles
|
||||
return fetchAllProfiles(session2, profiles, "mib_$username")
|
||||
lastProfiles = profiles // keep ALL profiles so settings can show them all
|
||||
|
||||
val hidden = credentialStore.getHiddenMibProfileIds()
|
||||
|
||||
// When the server already selected the profile and returned balances in A41
|
||||
// (single-profile case: profileSelected=true), use those accounts directly
|
||||
// without making an extra P47 call (which the server ignores or rejects).
|
||||
if (loginResp.optBoolean("profileSelected", false)) {
|
||||
val a41Balances = loginResp.optJSONArray("accountBalance")
|
||||
if (a41Balances != null && a41Balances.length() > 0) {
|
||||
val selectedId = loginResp.optString("selectedProfileId")
|
||||
val profile = profiles.firstOrNull { it.profileId == selectedId }
|
||||
?: profiles.firstOrNull()
|
||||
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
|
||||
val allAccounts = mutableListOf<MibAccount>()
|
||||
for (i in 0 until a41Balances.length()) {
|
||||
val a = a41Balances.getJSONObject(i)
|
||||
allAccounts.add(
|
||||
MibAccount(
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
cifType = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
accountTypeName = a.optString("accountTypeName"),
|
||||
availableBalance = a.optString("availableBalance"),
|
||||
currentBalance = a.optString("currentBalance"),
|
||||
blockedAmount = a.optString("blockedAmount"),
|
||||
mvrBalance = a.optString("mvrBalance"),
|
||||
statusDesc = a.optString("statusDesc"),
|
||||
profileImageHash = profile.customerImage,
|
||||
loginTag = "mib_$username",
|
||||
profileId = profile.profileId
|
||||
)
|
||||
)
|
||||
}
|
||||
return allAccounts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
|
||||
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
@@ -271,8 +313,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
val a = accountBalances.getJSONObject(i)
|
||||
allAccounts.add(
|
||||
MibAccount(
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
cifType = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
|
||||
@@ -20,8 +20,10 @@ data class MibProfile(
|
||||
)
|
||||
|
||||
data class MibAccount(
|
||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||
@@ -20,7 +21,8 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryAdapter(
|
||||
private val account: MibAccount
|
||||
private val account: MibAccount,
|
||||
private val display: AccountHistoryDisplay
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
@@ -138,7 +140,7 @@ class AccountHistoryAdapter(
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is HeaderVH -> holder.bind(account)
|
||||
is HeaderVH -> holder.bind(display)
|
||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
||||
else -> Unit
|
||||
@@ -147,37 +149,20 @@ class AccountHistoryAdapter(
|
||||
|
||||
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(acc: MibAccount) {
|
||||
b.tvHeaderAccountName.text = acc.accountBriefName
|
||||
b.tvHeaderAccountNumber.text = acc.accountNumber
|
||||
b.tvHeaderPillBank.text = when {
|
||||
acc.profileType.startsWith("BML") -> "BML"
|
||||
acc.profileType == "FAHIPAY" -> "FP"
|
||||
else -> null
|
||||
}
|
||||
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
|
||||
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
|
||||
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
|
||||
if (blocked > 0.0) {
|
||||
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
|
||||
fun bind(d: AccountHistoryDisplay) {
|
||||
b.tvHeaderAccountName.text = d.name
|
||||
b.tvHeaderAccountNumber.text = d.number
|
||||
b.tvHeaderPillBank.text = d.bankPill
|
||||
b.tvHeaderPillType.text = d.typeLabel
|
||||
b.tvHeaderAvailable.text = d.availableBalance
|
||||
b.tvHeaderBalance.text = d.workingBalance
|
||||
if (d.blockedBalance != null) {
|
||||
b.tvHeaderBlocked.text = d.blockedBalance
|
||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.llHeaderBlocked.visibility = View.GONE
|
||||
}
|
||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
|
||||
}
|
||||
|
||||
private fun friendlyType(raw: String): String {
|
||||
val u = raw.trim().uppercase()
|
||||
return when {
|
||||
u.contains("SAVING") -> "Savings"
|
||||
u.contains("CURRENT") -> "Current"
|
||||
u.contains("WADIAH") -> "Islamic"
|
||||
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
|
||||
u.contains("PREPAID") -> "Prepaid"
|
||||
else -> raw.trim().take(12)
|
||||
}
|
||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,23 +17,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||
import sh.sar.basedbank.util.AccountHistoryParser
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.HistoryFetcher
|
||||
import sh.sar.basedbank.util.MerchantIconCache
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryFragment : Fragment() {
|
||||
|
||||
@@ -43,21 +38,13 @@ class AccountHistoryFragment : Fragment() {
|
||||
|
||||
private lateinit var adapter: AccountHistoryAdapter
|
||||
private lateinit var account: MibAccount
|
||||
private lateinit var fetcher: HistoryFetcher
|
||||
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private var searchQuery = ""
|
||||
private var firstPageDone = false
|
||||
private val pendingImageNames = mutableSetOf<String>()
|
||||
private val pendingIconUrls = mutableSetOf<String>()
|
||||
|
||||
// Pagination state
|
||||
private var mibNextStart = 1
|
||||
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
|
||||
private var bmlNextPage = 1
|
||||
private var bmlTotalPages = -1
|
||||
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
|
||||
private var fahipayNextStart = 0
|
||||
private var fahipayTotal = -1
|
||||
private var isLoading = false
|
||||
private val pageSize = 10
|
||||
|
||||
@@ -79,8 +66,10 @@ class AccountHistoryFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
||||
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
||||
fetcher = HistoryFetcher(account)
|
||||
|
||||
adapter = AccountHistoryAdapter(account)
|
||||
val historyDisplay = AccountHistoryParser.from(account) ?: return
|
||||
adapter = AccountHistoryAdapter(account, historyDisplay)
|
||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||
adapter.onTransferClick = { acc ->
|
||||
@@ -104,11 +93,10 @@ class AccountHistoryFragment : Fragment() {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
searchQuery = s?.toString()?.trim() ?: ""
|
||||
filterAndDisplay()
|
||||
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
|
||||
if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
// Load cache immediately, then fetch fresh data in background
|
||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
@@ -133,19 +121,8 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
|
||||
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||
private fun isFahipay() = account.profileType == "FAHIPAY"
|
||||
|
||||
private fun hasMore(): Boolean = when {
|
||||
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
if (isLoading || !hasMore()) return
|
||||
if (isLoading || !fetcher.hasMore()) return
|
||||
isLoading = true
|
||||
|
||||
if (firstPageDone && allTransactions.isNotEmpty()) {
|
||||
@@ -155,68 +132,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
|
||||
when {
|
||||
isFahipay() -> {
|
||||
val session = app.fahipaySession ?: return@withContext emptyList()
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(session.sessionCookie)
|
||||
val (list, total) = flow.fetchHistory(
|
||||
session = session,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
start = fahipayNextStart
|
||||
)
|
||||
if (total > 0) fahipayTotal = total
|
||||
fahipayNextStart += list.size
|
||||
list
|
||||
}
|
||||
isMib() -> {
|
||||
val session = app.mibSession ?: return@withContext emptyList()
|
||||
app.mibMutex.withLock {
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
start = mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) mibTotalCount = total
|
||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
list
|
||||
}
|
||||
}
|
||||
isBmlCard() -> {
|
||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
cardMonthOffset++
|
||||
BmlLoginFlow().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
page = bmlNextPage
|
||||
)
|
||||
if (totalPages > 0) bmlTotalPages = totalPages
|
||||
bmlNextPage++
|
||||
list
|
||||
}
|
||||
}
|
||||
}
|
||||
val transactions = fetcher.fetchNextPage(app, pageSize)
|
||||
|
||||
isLoading = false
|
||||
|
||||
@@ -233,7 +149,6 @@ class AccountHistoryFragment : Fragment() {
|
||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
||||
if (searchQuery.isBlank()) {
|
||||
// Append incrementally to preserve scroll position
|
||||
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||
adapter.appendTransactions(sorted)
|
||||
binding.emptyView.visibility = View.GONE
|
||||
@@ -243,7 +158,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
}
|
||||
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
|
||||
if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
@@ -288,8 +203,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
val bytes = response.body?.bytes() ?: return@launch
|
||||
response.close()
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?: return@launch
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
MerchantIconCache.save(requireContext(), url, bitmap)
|
||||
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -8,23 +8,24 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.util.BmlDashboardParser
|
||||
import sh.sar.basedbank.util.MibAccountParser
|
||||
import sh.sar.basedbank.util.AccountListDisplay
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
|
||||
class AccountsAdapter(
|
||||
accounts: List<MibAccount>,
|
||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||
|
||||
private sealed class Item {
|
||||
data class SectionTitle(val label: String) : Item()
|
||||
data class Account(val account: MibAccount) : Item()
|
||||
data class Card(val account: MibAccount) : Item()
|
||||
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||
}
|
||||
|
||||
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
||||
@@ -36,38 +37,38 @@ class AccountsAdapter(
|
||||
}
|
||||
|
||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
||||
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||
val nonCards = displayed.filter { !it.second.isCard }
|
||||
val cards = displayed.filter { it.second.isCard }
|
||||
|
||||
// Group non-prepaid accounts by their derived section title, preserving order
|
||||
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
|
||||
for (acc in nonPrepaid) {
|
||||
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
|
||||
for ((acc, display) in nonCards) {
|
||||
val title = sectionTitle(acc)
|
||||
groups.getOrPut(title) { mutableListOf() }.add(acc)
|
||||
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
|
||||
}
|
||||
for ((title, group) in groups) {
|
||||
add(Item.SectionTitle(title))
|
||||
group.forEach { add(Item.Account(it)) }
|
||||
group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
|
||||
}
|
||||
|
||||
if (prepaid.isNotEmpty()) {
|
||||
if (cards.isNotEmpty()) {
|
||||
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
||||
prepaid.forEach { add(Item.Card(it)) }
|
||||
cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectionTitle(account: MibAccount): String {
|
||||
val profileLabel = when (account.profileType) {
|
||||
"0" -> "Personal"
|
||||
"1" -> "Business"
|
||||
else -> account.profileName
|
||||
val bankName = when (account.bank) {
|
||||
"BML" -> "Bank of Maldives"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
"MIB" -> "Maldives Islamic Bank"
|
||||
else -> account.bank
|
||||
}
|
||||
val bank = when {
|
||||
account.profileType.startsWith("BML") -> "Bank of Maldives"
|
||||
account.profileType == "FAHIPAY" -> "Fahipay"
|
||||
else -> "Maldives Islamic Bank"
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.cifType.ifBlank { account.profileName }
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||
@@ -79,17 +80,17 @@ class AccountsAdapter(
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
||||
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
||||
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
|
||||
is Item.Card -> (holder as CardViewHolder).bind(item.account)
|
||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
|
||||
is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,18 +105,15 @@ class AccountsAdapter(
|
||||
|
||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount) {
|
||||
binding.tvAccountName.text = account.accountBriefName
|
||||
binding.tvAccountNumber.text = account.accountNumber
|
||||
val label = if (account.profileType.startsWith("BML"))
|
||||
BmlDashboardParser.productLabel(account.accountTypeName)
|
||||
else
|
||||
MibAccountParser.productLabel(account.accountTypeName)
|
||||
binding.tvPillType.text = label
|
||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||
binding.tvAccountName.text = display.name
|
||||
binding.tvAccountNumber.text = display.number
|
||||
binding.tvAccountType.text = display.typeLabel
|
||||
binding.tvBalance.text = display.balance
|
||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
binding.root.setOnLongClickListener {
|
||||
copyToClipboard(it.context, account.accountNumber)
|
||||
copyToClipboard(it.context, display.number)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -123,23 +121,22 @@ class AccountsAdapter(
|
||||
|
||||
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount) {
|
||||
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName))
|
||||
binding.tvCardName.text = account.accountBriefName
|
||||
binding.tvCardNumber.text = account.accountNumber
|
||||
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName)
|
||||
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
|
||||
binding.tvCardName.text = display.name
|
||||
binding.tvCardNumber.text = display.number
|
||||
binding.tvCardProduct.text = display.typeLabel
|
||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
|
||||
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
||||
if (isActive) {
|
||||
binding.tvCardStatus.visibility = View.GONE
|
||||
binding.root.alpha = 1f
|
||||
} else {
|
||||
binding.tvCardStatus.text = account.statusDesc
|
||||
binding.tvCardBalance.text = display.balance
|
||||
if (display.statusLabel != null) {
|
||||
binding.tvCardStatus.text = display.statusLabel
|
||||
binding.tvCardStatus.visibility = View.VISIBLE
|
||||
binding.root.alpha = 0.45f
|
||||
} else {
|
||||
binding.tvCardStatus.visibility = View.GONE
|
||||
binding.root.alpha = 1f
|
||||
}
|
||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
}
|
||||
}
|
||||
@@ -154,13 +151,5 @@ class AccountsAdapter(
|
||||
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
||||
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun cardBrandIcon(productName: String): Int = when {
|
||||
productName.contains("AMEX", ignoreCase = true) ||
|
||||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
|
||||
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
|
||||
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
|
||||
else -> R.drawable.ic_nav_card
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ class AccountsFragment : Fragment() {
|
||||
adapter = AccountsAdapter(emptyList()) { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
}
|
||||
adapter.onTransferClick = { account ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
private fun buildDestinations(): List<DestinationOption> {
|
||||
val list = mutableListOf<DestinationOption>()
|
||||
for (profile in app.mibProfiles) {
|
||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
|
||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, subtitle = profile.cifType))
|
||||
}
|
||||
val store = CredentialStore(requireContext())
|
||||
for ((loginId, _) in app.bmlSessions) {
|
||||
|
||||
@@ -12,23 +12,23 @@ import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
|
||||
class ContactsAdapter(
|
||||
private val imageCache: MutableMap<String, Bitmap>,
|
||||
private val onImageNeeded: (hash: String) -> Unit,
|
||||
private val onDeleteClick: (MibBeneficiary) -> Unit,
|
||||
private val onTransferClick: (MibBeneficiary) -> Unit
|
||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
||||
private val onTransferClick: (ContactDisplay) -> Unit
|
||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||
|
||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
||||
private var displayed: List<MibBeneficiary> = emptyList()
|
||||
private var allContacts: List<ContactDisplay> = emptyList()
|
||||
private var displayed: List<ContactDisplay> = emptyList()
|
||||
|
||||
private var activeCategoryId: String? = null
|
||||
private var searchQuery: String = ""
|
||||
|
||||
fun updateContacts(contacts: List<MibBeneficiary>) {
|
||||
fun updateContacts(contacts: List<ContactDisplay>) {
|
||||
allContacts = contacts
|
||||
applyFilter()
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class ContactsAdapter(
|
||||
fun updateImage(hash: String, bitmap: Bitmap) {
|
||||
imageCache[hash] = bitmap
|
||||
displayed.forEachIndexed { index, contact ->
|
||||
if (contact.customerImgHash == hash) notifyItemChanged(index)
|
||||
if (contact.imageHash == hash) notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ class ContactsAdapter(
|
||||
|
||||
private fun applyFilter() {
|
||||
displayed = allContacts.filter { contact ->
|
||||
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
|
||||
val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
|
||||
val matchesSearch = searchQuery.isBlank() ||
|
||||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.benefAccount.contains(searchQuery)
|
||||
contact.name.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.realName.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.accountNumber.contains(searchQuery)
|
||||
matchesCategory && matchesSearch
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
@@ -76,7 +76,7 @@ class ContactsAdapter(
|
||||
binding.root.setOnLongClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
|
||||
val account = displayed[pos].benefAccount
|
||||
val account = displayed[pos].accountNumber
|
||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
|
||||
@@ -88,7 +88,7 @@ class ContactsAdapter(
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val contact = displayed[position]
|
||||
val cachedImage = contact.customerImgHash?.let { hash ->
|
||||
val cachedImage = contact.imageHash?.let { hash ->
|
||||
imageCache[hash] ?: run { onImageNeeded(hash); null }
|
||||
}
|
||||
holder.bind(contact, cachedImage)
|
||||
@@ -99,21 +99,24 @@ class ContactsAdapter(
|
||||
inner class ViewHolder(val binding: ItemContactBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
|
||||
val isFahipay = contact.benefType == "FAHIPAY"
|
||||
binding.tvContactName.text = contact.benefNickName
|
||||
binding.tvContactAccount.text = contact.benefAccount
|
||||
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}"
|
||||
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
fun bind(contact: ContactDisplay, photo: Bitmap?) {
|
||||
binding.tvContactName.text = contact.name
|
||||
binding.tvContactAccount.text = contact.accountNumber
|
||||
binding.tvRealName.text = contact.detail ?: ""
|
||||
binding.tvRealName.visibility =
|
||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnTransferContact.visibility =
|
||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnEditContact.visibility =
|
||||
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnDeleteContact.visibility =
|
||||
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
||||
|
||||
if (photo != null) {
|
||||
binding.ivContactPhoto.setImageBitmap(photo)
|
||||
} else {
|
||||
binding.ivContactPhoto.setImageBitmap(
|
||||
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
|
||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,15 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactListParser
|
||||
import sh.sar.basedbank.util.ContactManager
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
class ContactsFragment : Fragment() {
|
||||
|
||||
@@ -40,7 +42,7 @@ class ContactsFragment : Fragment() {
|
||||
private val app get() = requireActivity().application as BasedBankApp
|
||||
private val session get() = app.mibSession
|
||||
|
||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
||||
private var allContacts: List<ContactDisplay> = emptyList()
|
||||
private var currentSearch: String = ""
|
||||
private var mediator: TabLayoutMediator? = null
|
||||
private lateinit var pagerAdapter: ContactsPagerAdapter
|
||||
@@ -53,9 +55,9 @@ class ContactsFragment : Fragment() {
|
||||
private val density get() = resources.displayMetrics.density
|
||||
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
|
||||
ContactsAdapter(
|
||||
imageCache = sharedImageCache,
|
||||
onImageNeeded = { hash -> fetchImage(hash) },
|
||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||
imageCache = sharedImageCache,
|
||||
onImageNeeded = { hash -> fetchImage(hash) },
|
||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||
onTransferClick = { contact -> openTransfer(contact) }
|
||||
).also { a ->
|
||||
a.setFilter(page.categoryId, currentSearch)
|
||||
@@ -63,7 +65,7 @@ class ContactsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContacts(contacts: List<MibBeneficiary>) =
|
||||
fun updateContacts(contacts: List<ContactDisplay>) =
|
||||
contactAdapters.forEach { it.updateContacts(contacts) }
|
||||
|
||||
fun updateSearch(query: String) =
|
||||
@@ -124,8 +126,8 @@ class ContactsFragment : Fragment() {
|
||||
}
|
||||
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||
allContacts = contacts
|
||||
pagerAdapter.updateContacts(contacts)
|
||||
allContacts = ContactListParser.fromList(contacts)
|
||||
pagerAdapter.updateContacts(allContacts)
|
||||
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
@@ -150,31 +152,29 @@ class ContactsFragment : Fragment() {
|
||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||
}
|
||||
|
||||
private fun openTransfer(contact: MibBeneficiary) {
|
||||
private fun openTransfer(contact: ContactDisplay) {
|
||||
val fragment = TransferFragment.newInstance(
|
||||
accountNumber = contact.benefAccount,
|
||||
displayName = contact.benefNickName,
|
||||
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||
accountNumber = contact.accountNumber,
|
||||
displayName = contact.name,
|
||||
subtitle = contact.transferSubtitle,
|
||||
colorHex = contact.bankColor,
|
||||
imageHash = contact.customerImgHash
|
||||
imageHash = contact.imageHash
|
||||
)
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||
}
|
||||
|
||||
private fun confirmDelete(contact: MibBeneficiary) {
|
||||
private fun confirmDelete(contact: ContactDisplay) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.contact_delete_title)
|
||||
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName))
|
||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteContact(contact: MibBeneficiary) {
|
||||
private fun deleteContact(contact: ContactDisplay) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
|
||||
}
|
||||
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
|
||||
if (success) {
|
||||
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
|
||||
removeFromViewModel(contact)
|
||||
@@ -184,27 +184,10 @@ class ContactsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteBml(contact: MibBeneficiary): Boolean {
|
||||
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
||||
val contactId = contact.benefNo.removePrefix("bml_")
|
||||
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun deleteMib(contact: MibBeneficiary): Boolean {
|
||||
val sess = session ?: return false
|
||||
return try {
|
||||
if (contact.profileId.isNotBlank()) {
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
|
||||
}
|
||||
MibContactsClient().deleteContact(sess, contact.benefNo)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun removeFromViewModel(contact: MibBeneficiary) {
|
||||
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
|
||||
private fun removeFromViewModel(contact: ContactDisplay) {
|
||||
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
|
||||
viewModel.contacts.value = updated
|
||||
if (contact.benefCategoryId == "BML") {
|
||||
if (contact.network == TransferNetwork.BML) {
|
||||
updated.filter { it.benefCategoryId == "BML" }
|
||||
.groupBy { it.profileId }
|
||||
.forEach { (loginId, contacts) ->
|
||||
@@ -221,7 +204,6 @@ class ContactsFragment : Fragment() {
|
||||
|
||||
private fun fetchImage(hash: String) {
|
||||
if (!pendingHashes.add(hash)) return
|
||||
// Check disk cache first — if hash matches we already have the image
|
||||
val cached = ContactImageCache.load(requireContext(), hash)
|
||||
if (cached != null) {
|
||||
view?.post { pagerAdapter.updateImage(hash, cached) }
|
||||
|
||||
@@ -130,9 +130,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
val app = application as BasedBankApp
|
||||
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||
val mibAccounts = app.accounts.filter { !it.profileType.startsWith("BML") && it.profileType != "FAHIPAY" }
|
||||
val mibAccounts = app.accounts.filter { it.bank == "MIB" }
|
||||
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||
viewModel.accounts.value = merged
|
||||
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
|
||||
if (app.bmlAccounts.isNotEmpty()) {
|
||||
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
|
||||
@@ -145,7 +145,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cachedLimits = ForeignLimitsCache.load(this)
|
||||
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
||||
|
||||
refreshFinancing(app.mibSession, app.mibProfiles)
|
||||
refreshFinancing(app.mibSession, app.mibProfiles.filterVisibleProfiles())
|
||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||
} else {
|
||||
// Came from lock screen — show caches immediately, refresh everything in background
|
||||
@@ -364,12 +364,12 @@ class HomeActivity : AppCompatActivity() {
|
||||
// Immediately drop accounts for logged-out banks from the displayed list
|
||||
val current = viewModel.accounts.value ?: emptyList()
|
||||
viewModel.accounts.value = current.filter { acc ->
|
||||
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false
|
||||
if (acc.profileType.startsWith("BML")) {
|
||||
if (!hasMib && acc.bank == "MIB") return@filter false
|
||||
if (acc.bank == "BML") {
|
||||
val loginId = acc.loginTag.removePrefix("bml_")
|
||||
return@filter loginId in bmlLoginIds
|
||||
}
|
||||
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
|
||||
if (!hasFahipay && acc.bank == "FAHIPAY") return@filter false
|
||||
true
|
||||
}
|
||||
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
|
||||
@@ -395,6 +395,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
app.mibSession = flow.lastSession
|
||||
app.mibProfiles = flow.lastProfiles
|
||||
AccountCache.save(this@HomeActivity, accounts)
|
||||
CredentialStore(this@HomeActivity).saveMibProfiles(flow.lastProfiles)
|
||||
accounts
|
||||
} catch (_: Exception) { AccountCache.load(this@HomeActivity) }
|
||||
}
|
||||
@@ -488,14 +489,34 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
val app = application as BasedBankApp
|
||||
app.bmlAccounts = bmlAccounts
|
||||
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts)
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
|
||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||
refreshFinancing(app.mibSession, app.mibProfiles)
|
||||
refreshFinancing(app.mibSession, app.mibProfiles.filterVisibleProfiles())
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters MIB accounts whose profileId the user has hidden in settings. */
|
||||
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
|
||||
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds()
|
||||
if (hidden.isEmpty()) return this
|
||||
return filter { it.bank != "MIB" || it.profileId !in hidden }
|
||||
}
|
||||
|
||||
/** Filters MIB profiles the user has hidden — prevents API requests for hidden profiles. */
|
||||
private fun List<MibProfile>.filterVisibleProfiles(): List<MibProfile> {
|
||||
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds()
|
||||
if (hidden.isEmpty()) return this
|
||||
return filter { it.profileId !in hidden }
|
||||
}
|
||||
|
||||
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
||||
fun applyProfileVisibility() {
|
||||
val current = viewModel.accounts.value ?: return
|
||||
viewModel.accounts.value = current.filterVisibleAccounts()
|
||||
}
|
||||
|
||||
private fun refreshBmlLimits(session: BmlSession) {
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
lifecycleScope.launch {
|
||||
@@ -550,7 +571,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
|
||||
}
|
||||
// Refresh all banks in background
|
||||
refreshContacts(app.mibSession, app.mibProfiles)
|
||||
refreshContacts(app.mibSession, app.mibProfiles.filterVisibleProfiles())
|
||||
refreshBmlContacts(app)
|
||||
if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!)
|
||||
}
|
||||
@@ -630,7 +651,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val app = application as BasedBankApp
|
||||
lifecycleScope.launch {
|
||||
val current = viewModel.accounts.value ?: emptyList()
|
||||
if (src.profileType == "FAHIPAY") {
|
||||
if (src.bank == "FAHIPAY") {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.fahipaySession ?: return@withContext null
|
||||
try {
|
||||
@@ -645,9 +666,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
accounts
|
||||
} catch (_: Exception) { null }
|
||||
} ?: return@launch
|
||||
val others = current.filter { it.profileType != "FAHIPAY" }
|
||||
val others = current.filter { it.bank != "FAHIPAY" }
|
||||
viewModel.accounts.postValue(others + fresh)
|
||||
} else if (src.profileType.startsWith("BML")) {
|
||||
} else if (src.bank == "BML") {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
||||
@@ -659,20 +680,36 @@ class HomeActivity : AppCompatActivity() {
|
||||
accounts
|
||||
} catch (_: Exception) { null }
|
||||
} ?: return@launch
|
||||
val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag }
|
||||
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
|
||||
viewModel.accounts.postValue(otherAccounts + fresh)
|
||||
} else {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.mibSession ?: return@withContext null
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
|
||||
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
|
||||
catch (_: Exception) { null }
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
val hidden = store.getHiddenMibProfileIds()
|
||||
val allVisible = app.mibProfiles.filter { hidden.isEmpty() || it.profileId !in hidden }
|
||||
// Try P47 for all visible profiles (fast path, works for multi-profile)
|
||||
val sess = app.mibSession
|
||||
if (sess != null && allVisible.isNotEmpty()) {
|
||||
try {
|
||||
val accounts = MibLoginFlow(store).fetchAllProfiles(sess, allVisible, src.loginTag)
|
||||
if (accounts.isNotEmpty()) return@withContext accounts
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
// P47 returned nothing — re-login to get fresh balances (handles single-profile A41 path)
|
||||
val creds = store.loadMibCredentials() ?: return@withContext null
|
||||
try {
|
||||
val flow = MibLoginFlow(store)
|
||||
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||
app.mibSession = flow.lastSession
|
||||
app.mibProfiles = flow.lastProfiles
|
||||
store.saveMibProfiles(flow.lastProfiles)
|
||||
accounts.takeIf { it.isNotEmpty() }
|
||||
} catch (_: Exception) { null }
|
||||
} ?: return@launch
|
||||
// Replace accounts from this profile only, keep everything else
|
||||
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") }
|
||||
val merged = others + fresh
|
||||
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") })
|
||||
viewModel.accounts.postValue(merged)
|
||||
// Replace all MIB accounts with fresh data
|
||||
val others = current.filter { it.bank != "MIB" }
|
||||
AccountCache.save(this@HomeActivity, fresh)
|
||||
viewModel.accounts.postValue(others + fresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
@@ -14,6 +15,7 @@ import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
@@ -67,22 +69,9 @@ class SettingsLoginsFragment : Fragment() {
|
||||
if (hasMib) {
|
||||
val profile = store.loadMibUserProfile()
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||
val profileNames = AccountCache.load(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
||||
val mibProfiles = store.loadMibProfiles()
|
||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
||||
showLoginDetails(
|
||||
title = getString(R.string.mib_name),
|
||||
details = buildString {
|
||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
|
||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
|
||||
if (profileNames.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine(getString(R.string.login_detail_profiles))
|
||||
profileNames.forEach { appendLine(" • $it") }
|
||||
}
|
||||
}.trim(),
|
||||
onLogout = { confirmLogout(getString(R.string.mib_name)) { logoutMib(store) } }
|
||||
)
|
||||
showMibLoginDetails(store, profile, mibProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +143,117 @@ class SettingsLoginsFragment : Fragment() {
|
||||
container.addView(row)
|
||||
}
|
||||
|
||||
private fun showMibLoginDetails(
|
||||
store: CredentialStore,
|
||||
profile: CredentialStore.MibUserProfile?,
|
||||
mibProfiles: List<MibProfile>
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val originalHidden = store.getHiddenMibProfileIds()
|
||||
val hidden = originalHidden.toMutableSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||
}
|
||||
scroll.addView(container)
|
||||
|
||||
// Account info lines
|
||||
listOfNotNull(
|
||||
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
|
||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (mibProfiles.isNotEmpty()) {
|
||||
if (profile != null) {
|
||||
container.addView(View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
setBackgroundColor(0x1F000000)
|
||||
})
|
||||
}
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = getString(R.string.login_detail_profiles)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (8 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Build checkbox rows — wired up after dialog.show() so we can reference the Save button
|
||||
val checkboxRows = mibProfiles.map { p ->
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = p.name
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
})
|
||||
if (p.cifType.isNotBlank()) {
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = p.cifType
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val cb = CheckBox(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
row.addView(textCol)
|
||||
row.addView(cb)
|
||||
container.addView(row)
|
||||
p to cb
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.mib_name))
|
||||
.setView(scroll)
|
||||
.setPositiveButton(R.string.save, null) // null — set manually after show()
|
||||
.setNeutralButton(R.string.close, null)
|
||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.mib_name)) { logoutMib(store) }
|
||||
}
|
||||
.show()
|
||||
|
||||
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
saveBtn.isEnabled = false
|
||||
|
||||
checkboxRows.forEach { (p, cb) ->
|
||||
cb.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||
val atLeastOneVisible = mibProfiles.any { it.profileId !in hidden }
|
||||
saveBtn.isEnabled = hidden != originalHidden && atLeastOneVisible
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenMibProfileIds(hidden)
|
||||
clearAllCaches(ctx)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
|
||||
@@ -49,6 +49,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
@@ -209,16 +210,15 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun showFromCard(account: MibAccount) {
|
||||
val isBml = account.profileType.startsWith("BML")
|
||||
val colorHex = when {
|
||||
isBml -> "#0066A1"
|
||||
account.profileType == "FAHIPAY" -> "#15BEA7"
|
||||
else -> "#FE860E"
|
||||
val colorHex = when (account.bank) {
|
||||
"BML" -> "#0066A1"
|
||||
"FAHIPAY" -> "#15BEA7"
|
||||
else -> "#FE860E"
|
||||
}
|
||||
val bankLabel = when {
|
||||
isBml -> "BML"
|
||||
account.profileType == "FAHIPAY" -> "FP"
|
||||
else -> null
|
||||
val bankLabel = when (account.bank) {
|
||||
"BML" -> "BML"
|
||||
"FAHIPAY" -> "FP"
|
||||
else -> null
|
||||
}
|
||||
val typeLabel = when {
|
||||
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
||||
@@ -234,7 +234,7 @@ class TransferFragment : Fragment() {
|
||||
binding.tilFrom.visibility = View.GONE
|
||||
binding.cardFromInfo.visibility = View.VISIBLE
|
||||
|
||||
if (!isBml && account.profileImageHash != null) {
|
||||
if (account.bank != "BML" && account.profileImageHash != null) {
|
||||
loadFromPhoto(account.profileImageHash)
|
||||
}
|
||||
}
|
||||
@@ -312,7 +312,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
// Fahipay source: only phone numbers are supported
|
||||
if (selectedAccount?.profileType == "FAHIPAY") {
|
||||
if (selectedAccount?.bank == "FAHIPAY") {
|
||||
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
|
||||
lookupFahipayTarget(accountNumber)
|
||||
} else {
|
||||
@@ -328,7 +328,7 @@ class TransferFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
|
||||
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
|
||||
val isBmlSource = selectedAccount?.bank == "BML"
|
||||
|
||||
startLookupLoading()
|
||||
|
||||
@@ -567,7 +567,7 @@ class TransferFragment : Fragment() {
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
||||
|
||||
val isSrcBml = src.profileType.startsWith("BML")
|
||||
val isSrcBml = src.bank == "BML"
|
||||
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
|
||||
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||
val currency = src.currencyName.ifBlank { "MVR" }
|
||||
@@ -1009,11 +1009,11 @@ class TransferFragment : Fragment() {
|
||||
.also { it.root.tag = it }
|
||||
}
|
||||
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
||||
val isBmlAccount = acc.profileType.startsWith("BML")
|
||||
val isBmlAccount = acc.bank == "BML"
|
||||
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: ""
|
||||
b.root.alpha = if (inactive) 0.4f else 1f
|
||||
b.root
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ class TransferHistoryFragment : Fragment() {
|
||||
var fahipayTotal: Int = -1
|
||||
) {
|
||||
fun hasMore(): Boolean = when {
|
||||
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
||||
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
val results = mutableListOf<Transaction>()
|
||||
|
||||
// BML accounts: fetch in parallel
|
||||
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
|
||||
val bmlStates = activeStates.filter { it.account.bank == "BML" }
|
||||
results.addAll(bmlStates.map { state ->
|
||||
async {
|
||||
try {
|
||||
@@ -187,7 +187,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
}.awaitAll().flatten())
|
||||
|
||||
// Fahipay accounts
|
||||
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" }
|
||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||
for (state in fahipayStates) {
|
||||
val session = app.fahipaySession ?: continue
|
||||
try {
|
||||
@@ -206,9 +206,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||
val mibStates = activeStates.filter {
|
||||
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY"
|
||||
}
|
||||
val mibStates = activeStates.filter { it.account.bank == "MIB" }
|
||||
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
|
||||
val session = mibSession ?: break
|
||||
app.mibMutex.withLock {
|
||||
|
||||
@@ -194,6 +194,7 @@ class CredentialsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
AccountCache.save(requireContext(), accounts)
|
||||
CredentialStore(requireContext()).saveMibProfiles(flow.lastProfiles)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.accounts = accounts
|
||||
app.mibSession = flow.lastSession
|
||||
|
||||
@@ -17,8 +17,10 @@ object AccountCache {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("bank", acc.bank)
|
||||
put("profileName", acc.profileName)
|
||||
put("profileType", acc.profileType)
|
||||
put("cifType", acc.cifType)
|
||||
put("accountNumber", acc.accountNumber)
|
||||
put("accountBriefName", acc.accountBriefName)
|
||||
put("currencyName", acc.currencyName)
|
||||
@@ -68,20 +70,21 @@ object AccountCache {
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibAccount(
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
bank = "BML",
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
accountBriefName = o.optString("accountBriefName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
availableBalance = o.optString("availableBalance"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
profileImageHash = null,
|
||||
loginTag = o.optString("loginTag"),
|
||||
internalId = o.optString("internalId", "")
|
||||
loginTag = o.optString("loginTag"),
|
||||
internalId = o.optString("internalId", "")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
@@ -121,20 +124,21 @@ object AccountCache {
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibAccount(
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
bank = "FAHIPAY",
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
accountBriefName = o.optString("accountBriefName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
availableBalance = o.optString("availableBalance"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
profileImageHash = null,
|
||||
loginTag = o.optString("loginTag"),
|
||||
internalId = o.optString("internalId", "")
|
||||
loginTag = o.optString("loginTag"),
|
||||
internalId = o.optString("internalId", "")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
@@ -153,20 +157,22 @@ object AccountCache {
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibAccount(
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
bank = o.optString("bank", "MIB"),
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
cifType = o.optString("cifType", ""),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
accountBriefName = o.optString("accountBriefName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
availableBalance = o.optString("availableBalance"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
|
||||
loginTag = o.optString("loginTag"),
|
||||
profileId = o.optString("profileId", "")
|
||||
loginTag = o.optString("loginTag"),
|
||||
profileId = o.optString("profileId", "")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal file
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||
|
||||
object AccountListParser {
|
||||
|
||||
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
|
||||
"BML" -> BmlDashboardParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||
"MIB" -> MibAccountParser.displayData(account)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal file
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
/**
|
||||
* Standard display model for a contact row — produced by per-bank parsers,
|
||||
* consumed by UI. No bank-specific logic in adapters or fragments.
|
||||
*/
|
||||
data class ContactDisplay(
|
||||
val id: String, // internal contact ID (benefNo)
|
||||
val name: String, // display nickname
|
||||
val realName: String, // legal name — used for search
|
||||
val accountNumber: String,
|
||||
val categoryId: String, // for tab filtering
|
||||
val network: TransferNetwork,
|
||||
val bankColor: String,
|
||||
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
||||
val imageHash: String?,
|
||||
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
||||
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
||||
val canTransfer: Boolean,
|
||||
val canEdit: Boolean,
|
||||
val canDelete: Boolean
|
||||
)
|
||||
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal file
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.util.bmlapi.BmlContactParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
|
||||
import sh.sar.basedbank.util.mibapi.MibContactParser
|
||||
|
||||
object ContactListParser {
|
||||
|
||||
fun from(contact: MibBeneficiary): ContactDisplay? = when {
|
||||
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
|
||||
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
|
||||
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
|
||||
}
|
||||
37
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal file
37
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
|
||||
/**
|
||||
* Behaviour dispatcher for contact operations.
|
||||
* Routes add/delete to the correct bank API based on TransferNetwork.
|
||||
* UI code never inspects the network or bank type directly.
|
||||
*/
|
||||
object ContactManager {
|
||||
|
||||
/** Deletes [contact] via the appropriate bank API. Returns true on success. */
|
||||
suspend fun delete(contact: ContactDisplay, app: BasedBankApp): Boolean = when (contact.network) {
|
||||
TransferNetwork.BML -> deleteBml(contact, app)
|
||||
TransferNetwork.FAHIPAY -> false // Fahipay contacts are read-only
|
||||
TransferNetwork.MIB, TransferNetwork.LOCAL, TransferNetwork.SWIFT -> deleteMib(contact, app)
|
||||
}
|
||||
|
||||
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
||||
val contactId = contact.id.removePrefix("bml_")
|
||||
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||
val sess = app.mibSession ?: return false
|
||||
return try {
|
||||
if (contact.profileId.isNotBlank()) {
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
|
||||
}
|
||||
MibContactsClient().deleteContact(sess, contact.id)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
@@ -329,6 +329,43 @@ class CredentialStore(context: Context) {
|
||||
val birthdate: String
|
||||
)
|
||||
|
||||
// ── MIB operating profiles (all profiles, regardless of visibility) ───────
|
||||
|
||||
fun saveMibProfiles(profiles: List<sh.sar.basedbank.api.mib.MibProfile>) {
|
||||
val arr = org.json.JSONArray()
|
||||
for (p in profiles) {
|
||||
arr.put(org.json.JSONObject().apply {
|
||||
put("profileId", p.profileId)
|
||||
put("name", p.name)
|
||||
put("cifType", p.cifType)
|
||||
put("profileType", p.profileType)
|
||||
put("color", p.color)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString("mib_all_profiles", arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadMibProfiles(): List<sh.sar.basedbank.api.mib.MibProfile> {
|
||||
val raw = prefs.getString("mib_all_profiles", null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = org.json.JSONArray(raw)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
sh.sar.basedbank.api.mib.MibProfile(
|
||||
profileId = o.optString("profileId"),
|
||||
customerProfileId = o.optString("profileId"),
|
||||
annexId = "",
|
||||
customerId = "",
|
||||
name = o.optString("name"),
|
||||
cifType = o.optString("cifType"),
|
||||
profileType = o.optString("profileType"),
|
||||
color = o.optString("color"),
|
||||
customerImage = null
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun saveMibFullName(name: String) {
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
|
||||
@@ -399,6 +436,15 @@ class CredentialStore(context: Context) {
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ── MIB profile visibility ────────────────────────────────────────────────
|
||||
|
||||
/** Returns the set of MIB profile IDs the user has chosen to hide from the app. */
|
||||
fun getHiddenMibProfileIds(): Set<String> =
|
||||
prefs.getStringSet("mib_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||
|
||||
fun setHiddenMibProfileIds(ids: Set<String>) =
|
||||
prefs.edit().putStringSet("mib_hidden_profile_ids", ids).apply()
|
||||
|
||||
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
|
||||
115
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal file
115
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal file
@@ -0,0 +1,115 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Encapsulates all bank-specific pagination state and fetch logic for account history.
|
||||
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
|
||||
* without knowing which bank it is talking to.
|
||||
*/
|
||||
class HistoryFetcher(private val account: MibAccount) {
|
||||
|
||||
private val isMib get() = account.bank == "MIB"
|
||||
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||
|
||||
// MIB pagination
|
||||
private var mibNextStart = 1
|
||||
private var mibTotalCount = -1
|
||||
|
||||
// BML CASA pagination
|
||||
private var bmlNextPage = 1
|
||||
private var bmlTotalPages = -1
|
||||
|
||||
// BML card pagination (month-based)
|
||||
private var cardMonthOffset = 0
|
||||
|
||||
// Fahipay pagination
|
||||
private var fahipayNextStart = 0
|
||||
private var fahipayTotal = -1
|
||||
|
||||
fun hasMore(): Boolean = when {
|
||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard -> cardMonthOffset < 3
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
}
|
||||
|
||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
|
||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||
}
|
||||
|
||||
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
|
||||
val session = app.fahipaySession ?: return emptyList()
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(session.sessionCookie)
|
||||
val (list, total) = flow.fetchHistory(
|
||||
session = session,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
start = fahipayNextStart
|
||||
)
|
||||
if (total > 0) fahipayTotal = total
|
||||
fahipayNextStart += list.size
|
||||
return list
|
||||
}
|
||||
|
||||
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
|
||||
val session = app.mibSession ?: return emptyList()
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
start = mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) mibTotalCount = total
|
||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
return list
|
||||
}
|
||||
|
||||
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
|
||||
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
cardMonthOffset++
|
||||
return BmlLoginFlow().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
|
||||
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
page = bmlNextPage
|
||||
)
|
||||
if (totalPages > 0) bmlTotalPages = totalPages
|
||||
bmlNextPage++
|
||||
return list
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal file
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
/** App-unified term for the transfer routing network a contact uses. */
|
||||
enum class TransferNetwork {
|
||||
MIB, // MIB internal (both parties on MIB)
|
||||
LOCAL, // local inter-bank via IPS (e.g. BML from MIB's side)
|
||||
SWIFT, // international SWIFT
|
||||
BML, // Bank of Maldives
|
||||
FAHIPAY // Fahipay wallet
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -37,9 +37,17 @@
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccountType"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: segmented pill (bank | type) + balance -->
|
||||
<!-- Right: balance + transfer button -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -47,31 +55,22 @@
|
||||
android:gravity="end"
|
||||
android:layout_marginStart="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/pill_segment_bg">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPillType"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBalance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginTop="6dp" />
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnTransfer"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_send"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:contentDescription="Transfer" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Status pill + balance (prepaid only) -->
|
||||
<!-- Transfer button + balance -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutCardBalance"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -85,6 +85,16 @@
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginTop="6dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnTransfer"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_send"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:contentDescription="Transfer" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
<string name="login_detail_id_card">ID Card</string>
|
||||
<string name="login_detail_profiles">Profiles</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
|
||||
<!-- Home -->
|
||||
|
||||
85
docs/PARSERS.md
Normal file
85
docs/PARSERS.md
Normal 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.
|
||||
Reference in New Issue
Block a user