optimize contact picker ui to disable forbidden transfers

This commit is contained in:
2026-05-14 01:53:40 +05:00
parent 6ed68df572
commit 8119d554cf
7 changed files with 89 additions and 28 deletions

View File

@@ -164,7 +164,7 @@ class BmlLoginFlow {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val json = resp.body?.string() ?: return emptyList()
resp.close()
return parseDashboard(json)
return parseDashboard(json, "bml_${session.deviceId}")
}
fun fetchContacts(session: BmlSession): List<MibBeneficiary> {
@@ -181,7 +181,7 @@ class BmlLoginFlow {
.header("x-app-version", APP_VERSION)
.build()
private fun parseDashboard(json: String): List<MibAccount> {
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
@@ -211,7 +211,8 @@ class BmlLoginFlow {
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null
profileImageHash = null,
loginTag = loginTag
))
} else if (accountType == "Card") {
val isPrepaid = item.optBoolean("prepaid_card", false)
@@ -230,7 +231,8 @@ class BmlLoginFlow {
blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null
profileImageHash = null,
loginTag = loginTag
))
} else {
// Linked debit cards have no independent balance or account link — skip

View File

@@ -125,7 +125,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
lastSession = session2
lastProfiles = profiles
return fetchAllProfiles(session2, profiles)
return fetchAllProfiles(session2, profiles, "mib_$username")
}
// ─── Helpers ─────────────────────────────────────────────────────────────
@@ -204,7 +204,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
doRequest(session, payload, "n")
}
private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>): List<MibAccount> {
private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>, loginTag: String): List<MibAccount> {
val allAccounts = mutableListOf<MibAccount>()
for (profile in profiles) {
val payload = baseData(session, "P47").apply {
@@ -231,7 +231,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage
profileImageHash = profile.customerImage,
loginTag = loginTag
)
)
}

View File

@@ -31,7 +31,8 @@ data class MibAccount(
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String,
val profileImageHash: String?
val profileImageHash: String?,
val loginTag: String = ""
)
data class MibBeneficiaryCategory(

View File

@@ -7,7 +7,9 @@ import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
@@ -27,7 +29,8 @@ class ContactPickerAdapter(
val colorHex: String,
val isSameAsFrom: Boolean = false,
val isManualEntry: Boolean = false,
val imageHash: String? = null
val imageHash: String? = null,
val inactiveReason: String? = null
) : PickerItem()
}
@@ -96,10 +99,15 @@ class ContactPickerAdapter(
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
}
binding.root.alpha = if (item.isSameAsFrom) 0.4f else 1.0f
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
binding.root.setOnClickListener {
if (item.isSameAsFrom) onSameAsFrom()
else onItemClick(item.accountNumber, item.displayName)
when {
item.inactiveReason != null ->
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
item.isSameAsFrom ->
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
else -> onItemClick(item.accountNumber, item.displayName)
}
}
binding.root.setOnLongClickListener { view ->
onItemLongClick?.invoke(item.accountNumber, view) ?: false

View File

@@ -76,10 +76,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
binding.etSheetSearch.addTextChangedListener { rebuildList(selectedAccountNumber) }
// Tabs: Recents | All | <categories...>
// Tabs: Recents | My Accounts | All | <categories...>
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_recents).apply { tag = RECENTS_TAG }
)
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.transfer_my_accounts).apply { tag = MY_ACCOUNTS_TAG }
)
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_all).apply { tag = null }
)
@@ -93,8 +96,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
})
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
// Remove all tabs after "All" (index 1) and re-add categories
while (binding.sheetCategoryTabs.tabCount > 2) binding.sheetCategoryTabs.removeTabAt(2)
// Remove all tabs after "All" (index 2) and re-add categories
while (binding.sheetCategoryTabs.tabCount > 3) binding.sheetCategoryTabs.removeTabAt(3)
for (cat in cats) {
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(cat.categoryName).apply { tag = cat.id }
@@ -133,25 +136,63 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val fromAccount = accounts.find { it.accountNumber == fromNumber }
val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID"
if (activeCategoryId == null) {
val filtered = if (search.isBlank()) accounts else accounts.filter {
if (activeCategoryId == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
}
if (filtered.isNotEmpty()) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.transfer_my_accounts)))
for (acc in filtered) {
if (filteredRegular.isNotEmpty()) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.accounts)))
for (acc in filteredRegular) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromNumber
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E",
isSameAsFrom = acc.accountNumber == fromNumber,
imageHash = acc.profileImageHash
isSameAsFrom = isSame,
imageHash = acc.profileImageHash,
inactiveReason = if (isSame) null
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName)
))
}
}
val filteredCards = if (search.isBlank()) cards else cards.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
}
if (filteredCards.isNotEmpty()) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.cards)))
for (acc in filteredCards) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromNumber
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E",
isSameAsFrom = isSame,
imageHash = acc.profileImageHash,
inactiveReason = if (isSame) null
else if (!isActive) acc.statusDesc
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName)
))
}
}
adapter.submitList(items)
return
}
val filteredContacts = contacts.filter { contact ->
@@ -164,9 +205,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
}
if (filteredContacts.isNotEmpty()) {
if (activeCategoryId == null) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.nav_contacts)))
}
for (contact in filteredContacts) {
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = contact.benefAccount,
@@ -174,7 +212,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
isSameAsFrom = contact.benefAccount == fromNumber,
imageHash = contact.customerImgHash
imageHash = contact.customerImgHash,
inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
))
}
}
@@ -203,6 +242,9 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
}
}
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
override fun onDestroyView() {
super.onDestroyView()
_binding = null
@@ -214,6 +256,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
const val KEY_LABEL = "label"
private const val ARG_FROM_ACCOUNT = "fromAccount"
private const val RECENTS_TAG = "__recents__"
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
fun newInstance(fromAccountNumber: String) = ContactPickerSheetFragment().apply {
arguments = bundleOf(ARG_FROM_ACCOUNT to fromAccountNumber)

View File

@@ -26,6 +26,7 @@ object AccountCache {
put("blockedAmount", acc.blockedAmount)
put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag)
if (acc.profileImageHash != null) put("profileImageHash", acc.profileImageHash)
})
}
@@ -48,6 +49,7 @@ object AccountCache {
put("blockedAmount", acc.blockedAmount)
put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -73,7 +75,8 @@ object AccountCache {
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = null
profileImageHash = null,
loginTag = o.optString("loginTag")
)
}
} catch (e: Exception) { emptyList() }
@@ -98,7 +101,8 @@ object AccountCache {
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() }
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
loginTag = o.optString("loginTag")
)
}
} catch (e: Exception) {

View File

@@ -92,7 +92,9 @@
<string name="lang_dhivehi">ދިވެހި</string>
<!-- Home -->
<string name="transfer_same_account">This is your source account</string>
<string name="accounts">Accounts</string>
<string name="cards">Cards</string>
<string name="available_balance">Available Balance</string>
<!-- Transfer -->