Compare commits

..
19 Commits
Author SHA1 Message Date
shihaam e7f7590654 release version 1.0.25
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m17s
2026-09-25 04:45:25 +05:00
shihaam a048be939c Merge branch 'fix/show-currency-of-other-account-in-transfer-page'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:42:36 +05:00
shihaam 102a986201 Merge branch 'fix/contact-add-page-account-number-enter-behaviour'
Auto Tag on Version Change / check-version (push) Failing after 3s
# Conflicts:
#	app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt
2026-09-25 04:37:22 +05:00
shihaam 440db13a0f also add search button to keyboard in transfer page to feild and move focus to amount feild on search search or to field fill 2026-09-25 04:35:31 +05:00
shihaam 0dc81fef62 Merge branch 'feat/hide-accounts'
Auto Tag on Version Change / check-version (push) Failing after 3s
2026-09-25 04:21:08 +05:00
shihaam 7e52b510a1 disable unnessary api requests on saving profile/account disable and also disable all accounts for a profile when that profile is disabled 2026-09-25 04:20:40 +05:00
shihaam 0d7833af5c Merge branch 'feat/search-saved-contacts-in-account-number-field'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:05:05 +05:00
shihaam 9e45e36c0a Merge branch 'feat/login-sort-order'
Auto Tag on Version Change / check-version (push) Failing after 4s
2026-09-25 04:02:12 +05:00
shihaam c883b6b521 added margin between icon and edge of selection in login list 2026-09-25 03:52:07 +05:00
shihaam 5dc37aadf0 update docs 2026-09-25 03:46:35 +05:00
shihaam 1e3cf3420b login sorting fixed 2026-09-25 03:43:58 +05:00
shihaam d98dcfd407 auto select from account after to account is selected 2026-09-25 03:19:38 +05:00
flamexode 3b55fa30a8 show currency after searching for account
fixes #40
2026-09-21 21:56:23 +05:00
flamexode 7f0f2707a1 default name to title case of API response name 2026-09-21 18:02:56 +05:00
flamexode 117733b766 change keyboard enter behaviour of add contacts account number field
fixes #43
2026-09-21 17:58:48 +05:00
flamexode d769df6f6b add ability to hide accounts
implements #50
2026-09-21 17:47:26 +05:00
flamexode caf0db60da autocomplete with saved contacts in transfer page
implements #49
2026-09-21 17:47:08 +05:00
flamexode b48022d249 add ability to reorder logins
fixes #47
2026-09-21 17:14:06 +05:00
shihaam 7574a5e8b0 release v1.0.25 - idk numbers
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m33s
2026-09-21 16:30:19 +05:00
26 changed files with 795 additions and 129 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 23
versionName = "1.0.24"
versionCode = 27
versionName = "1.0.26"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -234,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun setupAccountSearch() {
binding.tilAccount.setEndIconOnClickListener { performLookup() }
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
performLookup()
true
} else false
}
}
private fun performLookup() {
@@ -387,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// Auto-fill alias with existing alias or name
if (binding.etAlias.text.isNullOrBlank()) {
binding.etAlias.setText(validation.name)
binding.etAlias.setText(sh.sar.basedbank.util.bmlapi.BmlDashboardParser.toTitleCase(validation.name))
}
binding.etCurrency.setText(validation.currency)
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
.map { CardItem.Bml(it) }
val all = bmlItems + mibItems
val rank = credStore.loginRank()
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
val defaultNum = credStore.getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
notifyDataSetChanged()
}
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
expandedPositions.clear()
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
val mibItems = mibDeals.map { Item.Mib(it) }
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
notifyDataSetChanged()
}
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentFinancingBinding
import sh.sar.basedbank.util.CredentialStore
class FinancingFragment : Fragment() {
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
adapter.update(latestMibDeals, bmlLoans)
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
val order = CredentialStore(requireContext()).getLoginOrder()
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
@@ -244,9 +244,9 @@ class HomeActivity : AppCompatActivity() {
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
if (merged.isNotEmpty()) viewModel.accounts.value = merged
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
val cachedCards = CardsCache.load(this)
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
@@ -774,47 +774,13 @@ fun applyNavLabelVisibility() {
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
val bmlClient = BmlAccountClient()
// Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId)
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
if (saved == null) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
continue
}
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
suspend fun fetchWithSession(session: BmlSession) {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
}
suspend fun tryRefresh() {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
fetchWithSession(newSession)
}
if (profile.profileId in hiddenProfiles) continue
try {
if (tokenKnownExpired) {
tryRefresh()
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
@@ -985,15 +951,17 @@ fun applyNavLabelVisibility() {
refreshBmlLoanDetails()
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles)
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
}
/** Filters accounts whose profileId the user has hidden in settings. */
/** Filters accounts whose profileId or account number the user has hidden in settings. */
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
val store = CredentialStore(this@HomeActivity)
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
return filter { acc ->
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
when (acc.bank) {
"MIB" -> {
val loginId = acc.loginTag.removePrefix("mib_")
@@ -1022,10 +990,140 @@ fun applyNavLabelVisibility() {
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()
/** Drops MIB cards belonging to profiles the user has hidden. */
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
val store = CredentialStore(this@HomeActivity)
return filter { card ->
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
}
}
/**
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
*/
fun applyVisibility() {
val app = application as BasedBankApp
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
viewModel.mibCards.value?.let { cards ->
val visible = cards.filterVisibleCards()
viewModel.mibCards.value = visible
CardsCache.save(this, visible)
}
}
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
if (profileIds.isEmpty()) return
val app = application as BasedBankApp
val loginTag = "mib_$loginId"
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val fresh = withContext(Dispatchers.IO) {
val session = app.mibSessions[loginId]
if (session != null && enabled.isNotEmpty()) {
try {
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
if (accounts.isNotEmpty()) {
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
}
} catch (_: Exception) { }
}
// Session expired — log this one login in again (it now includes the unhidden profiles)
val store = CredentialStore(this@HomeActivity)
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
try {
val flow = MibLoginFlow(store)
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
} catch (_: Exception) { null }
}
binding.refreshIndicator.visibility = View.GONE
if (fresh == null) return@launch
app.mibAccounts = fresh
AccountCache.save(this@HomeActivity, fresh)
applyVisibility()
val session = app.mibSessions[loginId] ?: return@launch
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
// Financing deals carry no profile id, so the login's visible profiles are fetched together
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
if (profileIds.isEmpty()) return
val app = application as BasedBankApp
val store = CredentialStore(this)
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
if (enabled.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val fetched = withContext(Dispatchers.IO) {
enabled.mapNotNull { profile ->
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
}
}
binding.refreshIndicator.visibility = View.GONE
if (fetched.isEmpty()) return@launch
val fetchedIds = fetched.map { it.first }.toSet()
val loginTag = "bml_$loginId"
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
fetched.flatMap { it.second }
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
applyVisibility()
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
refreshBmlLoanDetails()
}
}
/**
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
* Returns null when the profile has no saved session; throws on network/server errors.
*/
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
val app = application as BasedBankApp
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
val bmlClient = BmlAccountClient()
fun fetchWithSession(session: BmlSession): List<BankAccount> {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
return accounts
}
fun tryRefresh(): List<BankAccount> {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
return fetchWithSession(newSession)
}
return if (tokenKnownExpired) {
tryRefresh()
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
}
private fun refreshBmlLimits(session: BmlSession) {
@@ -1259,7 +1357,7 @@ fun applyNavLabelVisibility() {
val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles)
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
@@ -1284,7 +1382,8 @@ fun applyNavLabelVisibility() {
}
if (cards.isNotEmpty()) {
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
existing.removeAll { it.loginTag == "mib_$loginId" }
val fetchedIds = profiles.map { it.profileId }.toSet()
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
existing += cards
viewModel.mibCards.postValue(existing)
CardsCache.save(this@HomeActivity, existing)
@@ -1,7 +1,8 @@
package sh.sar.basedbank.ui.home
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.CredentialStore
sealed class CardItem {
data class Mib(val card: MibCard) : CardItem()
data class Bml(val account: BankAccount) : CardItem()
abstract val loginTag: String
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
}
class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
class HomeViewModel(application: Application) : AndroidViewModel(application) {
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
override fun setValue(value: List<BankAccount>?) {
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
}
}
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
/** BML loan details keyed by account internalId. */
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
val mibCards = MutableLiveData<List<MibCard>?>(null)
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
override fun setValue(value: List<MibCard>?) {
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
}
}
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
/** Re-applies the login order after the user changes it. */
fun resortByLoginOrder() {
accounts.value = accounts.value
mibCards.value = mibCards.value
}
val hideAmounts = MutableLiveData<Boolean>(false)
@@ -86,17 +86,19 @@ class OtpFragment : Fragment() {
val store = CredentialStore(requireContext())
val app = requireActivity().application as BasedBankApp
val entries = mutableListOf<OtpEntry>()
val rank = store.loginRank()
val tagged = mutableListOf<Pair<String, OtpEntry>>()
for (loginId in store.getMibLoginIds()) {
val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
}
for (loginId in store.getBmlLoginIds()) {
val creds = store.loadBmlCredentials(loginId) ?: continue
val name = store.loadBmlUserProfile(loginId)?.fullName
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
}
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
val adapter = OtpAdapter(entries)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
@@ -904,7 +904,9 @@ class CardsFragment : Fragment() {
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
val rank = store.loginRank()
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
// Move default BML card to front
cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.annotation.SuppressLint
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
@@ -30,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity
@@ -359,65 +363,86 @@ class SettingsLoginsFragment : Fragment() {
_binding = null
}
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
private fun buildLoginsSection() {
val ctx = requireContext()
val store = CredentialStore(ctx)
val container = binding.loginsContainer
container.removeAllViews()
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds()
val mfaisaLoginIds = store.getMfaisaLoginIds()
val entries = mutableListOf<LoginEntry>()
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
for (loginId in mibLoginIds) {
for (loginId in store.getMibLoginIds()) {
val profile = store.loadMibUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
val mibProfiles = store.loadMibProfiles(loginId)
addLoginRow(container, R.drawable.mib_logo, displayName) {
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
showMibLoginDetails(store, loginId, profile, mibProfiles)
}
}
for (loginId in bmlLoginIds) {
for (loginId in store.getBmlLoginIds()) {
val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val bmlProfiles = store.loadBmlProfiles(loginId)
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
}
}
for (loginId in fahipayLoginIds) {
for (loginId in store.getFahipayLoginIds()) {
val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
showFahipayLoginDetails(store, loginId, profile)
}
}
for (loginId in mfaisaLoginIds) {
for (loginId in store.getMfaisaLoginIds()) {
val profile = store.loadMfaisaUserProfile(loginId)
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
showMfaisaLoginDetails(store, loginId, profile)
}
}
val rank = store.loginRank()
val sorted = entries.sortedBy { rank(it.key) }
val draggable = sorted.size > 1
for (entry in sorted) {
addLoginRow(
container, entry.logoRes, entry.displayName, entry.onClick,
dragTag = entry.key.takeIf { draggable },
onReordered = { order ->
store.setLoginOrder(order)
viewModel.resortByLoginOrder()
}
)
}
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
}
private fun addLoginRow(
container: LinearLayout,
logoRes: Int,
displayName: String,
onClick: () -> Unit,
dragTag: String? = null,
onReordered: (List<String>) -> Unit = {}
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
isClickable = true; isFocusable = true
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
setOnClickListener { onClick() }
tag = dragTag
}
val logo = ImageView(ctx).apply {
setImageResource(logoRes)
@@ -430,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
row.addView(logo); row.addView(tvName)
if (dragTag != null) {
val handle = ImageView(ctx).apply {
setImageResource(R.drawable.ic_reorder_lines)
scaleType = ImageView.ScaleType.CENTER_INSIDE
contentDescription = getString(R.string.drag_to_reorder)
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
}
row.addView(handle)
attachDragHandle(handle, row, container, onReordered)
}
container.addView(row)
}
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
@SuppressLint("ClickableViewAccessibility")
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
val ctx = handle.context
val lift = 8 * ctx.resources.displayMetrics.density
val dragBg = android.graphics.drawable.ColorDrawable(
com.google.android.material.color.MaterialColors.getColor(
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
var lastY = 0f
var startIndex = 0
var savedBg: android.graphics.drawable.Drawable? = null
handle.setOnTouchListener { v, ev ->
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
v.parent.requestDisallowInterceptTouchEvent(true)
lastY = ev.rawY
startIndex = group.indexOfChild(row)
row.animate().cancel()
savedBg = row.background
row.background = dragBg
row.translationZ = lift
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
MotionEvent.ACTION_MOVE -> {
row.translationY += ev.rawY - lastY
lastY = ev.rawY
var index = group.indexOfChild(row)
while (index < group.childCount - 1) {
val next = group.getChildAt(index + 1)
if (row.translationY <= next.height / 2f) break
group.removeView(next); group.addView(next, index)
row.translationY -= next.height
next.translationY = row.height.toFloat()
next.animate().translationY(0f).setDuration(150).start()
index++
}
while (index > 0) {
val prev = group.getChildAt(index - 1)
if (row.translationY >= -prev.height / 2f) break
group.removeView(prev); group.addView(prev, index)
row.translationY += prev.height
prev.translationY = -row.height.toFloat()
prev.animate().translationY(0f).setDuration(150).start()
index--
}
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
v.parent.requestDisallowInterceptTouchEvent(false)
row.animate().translationY(0f).translationZ(0f).setDuration(150)
.withEndAction { row.background = savedBg }
.start()
if (group.indexOfChild(row) != startIndex) {
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
}
}
}
true
}
}
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
private fun addAccountRow(
ctx: Context,
container: LinearLayout,
dp: Float,
acc: BankAccount,
hiddenAccounts: MutableSet<String>,
indent: Boolean
): Pair<BankAccount, MaterialSwitch> {
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()
if (indent) it.marginStart = (28 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
textCol.addView(TextView(ctx).apply {
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
setTextAppearance(nameAppearance)
})
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
else acc.accountTypeName.trim()
textCol.addView(TextView(ctx).apply {
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
.joinToString(" · ")
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val toggle = MaterialSwitch(ctx).apply {
isChecked = acc.accountNumber !in hiddenAccounts
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = (4 * dp).toInt()
}
}
row.addView(textCol)
row.addView(toggle)
container.addView(row)
return acc to toggle
}
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
private fun addNestedAccountRows(
ctx: Context,
container: LinearLayout,
dp: Float,
accounts: List<BankAccount>,
hiddenAccounts: MutableSet<String>
): List<Pair<BankAccount, MaterialSwitch>> =
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
private fun addAccountsSection(
ctx: Context,
container: LinearLayout,
dp: Float,
accounts: List<BankAccount>,
hiddenAccounts: MutableSet<String>,
showDivider: Boolean
): List<Pair<BankAccount, MaterialSwitch>> {
if (accounts.isEmpty()) return emptyList()
if (showDivider) {
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.accounts)
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()
}
})
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
}
/**
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
* the account listeners ignore changes made while a toggle is disabled.
*/
private fun syncAccountToggles(
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
hiddenAccounts: Set<String>,
isParentHidden: (BankAccount) -> Boolean
) {
accountRows.forEach { (acc, toggle) ->
if (isParentHidden(acc)) {
toggle.isEnabled = false
toggle.isChecked = false
} else {
toggle.isChecked = acc.accountNumber !in hiddenAccounts
toggle.isEnabled = true
}
}
}
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
}
private fun showMibLoginDetails(
store: CredentialStore,
loginId: String,
@@ -443,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenMibProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -485,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
})
}
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
// Build toggle rows — wired up after dialog.show() so we can reference the Save button.
// Each profile's own accounts nest directly beneath it (tree view), since one profile
// can have multiple accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
@@ -526,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
row.addView(pencil)
row.addView(toggle)
container.addView(row)
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
p to toggle
}
// Accounts that don't belong to any known profile still need to be reachable.
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
accountRows += addAccountsSection(
ctx, container, dp, unassignedAccounts, hiddenAccounts,
showDivider = mibProfiles.isNotEmpty() || profile != null
)
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = mibProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (p, toggle) ->
// Disable the sole remaining visible toggle so it can't be turned off
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
@@ -559,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
}
}
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
store.setHiddenMibProfileIds(loginId, hidden)
clearAllCaches(ctx)
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
// Hiding is applied offline; only profiles that were just unhidden get fetched
val home = activity as? HomeActivity
home?.applyVisibility()
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
}
}
@@ -585,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
}
val originalHidden = hidden.toSet()
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -630,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
})
}
// Each profile's own accounts nest directly beneath it (tree view), since one profile
// can have multiple accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = bmlProfiles.map { p ->
val avatarIv = makeCircleAvatarView(ctx, 36)
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
@@ -677,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
row.addView(pencil)
row.addView(toggle)
container.addView(row)
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
p to toggle
}
// Accounts that don't belong to any known profile still need to be reachable.
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
accountRows += addAccountsSection(
ctx, container, dp, unassignedAccounts, hiddenAccounts,
showDivider = bmlProfiles.isNotEmpty() || profile != null
)
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
@@ -720,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
}
}
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx)
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
// Hiding is applied offline; only profiles that were just unhidden get fetched
val home = activity as? HomeActivity
home?.applyVisibility()
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
}
}
@@ -947,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
val dp = ctx.resources.displayMetrics.density
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -997,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
})
}
MaterialAlertDialogBuilder(ctx)
val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.fahipay_name))
.setView(scroll)
.setPositiveButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
.apply {
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
setNeutralButton(R.string.close, null)
setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
}
}
.show()
if (loginAccounts.isNotEmpty()) {
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
}
}
saveBtn.setOnClickListener {
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.applyVisibility()
}
}
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
@@ -1087,6 +1379,8 @@ class SettingsLoginsFragment : Fragment() {
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
val originalHidden = hidden.toSet()
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
// Each toggle covers the set of pocket account numbers that belong to that profile.
@@ -1142,6 +1436,9 @@ class SettingsLoginsFragment : Fragment() {
})
}
// Each group's own pockets nest directly beneath it (tree view), since a group
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = profileRows.map { row ->
val v = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
@@ -1164,6 +1461,7 @@ class SettingsLoginsFragment : Fragment() {
v.addView(label)
v.addView(toggle)
container.addView(v)
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
row to toggle
}
@@ -1172,7 +1470,8 @@ class SettingsLoginsFragment : Fragment() {
toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
@@ -1199,11 +1498,20 @@ class SettingsLoginsFragment : Fragment() {
}
}
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
// All pockets come back in one login response, so hiding/unhiding never needs a request
store.setHiddenMfaisaPocketIds(loginId, hidden)
clearAllCaches(ctx)
persistHiddenAccounts(store, pockets, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
(activity as? HomeActivity)?.applyVisibility()
}
}
}
@@ -40,9 +40,11 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
@@ -92,6 +94,7 @@ class TransferFragment : Fragment() {
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private var accountDropdownAdapter: AccountDropdownAdapter? = null
private var contactDropdownAdapter: ContactDropdownAdapter? = null
/**
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
@@ -305,6 +308,9 @@ class TransferFragment : Fragment() {
setupFromDropdown()
setupAccountLookup()
// Contact search dropdown on the To field needs viewModel.contacts populated —
// otherwise it stays empty until the contacts tab or picker sheet is opened first.
(activity as? HomeActivity)?.loadAllContacts()
viewModel.hideAmounts.observe(viewLifecycleOwner) {
accountDropdownAdapter?.notifyDataSetChanged()
@@ -327,7 +333,7 @@ class TransferFragment : Fragment() {
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
if (selectedAccount?.bank == "MFAISA") {
binding.etTo.setText(accountNumber)
binding.etTo.setText(accountNumber, false)
mfaisaHandler().searchRecipient(accountNumber)
return@setFragmentResultListener
}
@@ -336,18 +342,8 @@ class TransferFragment : Fragment() {
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
selectDefaultSourceIfNone()
focusAmount()
}
binding.btnPickContact.setOnClickListener {
@@ -408,7 +404,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.VISIBLE
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
} else if (savedToText.isNotEmpty()) {
binding.etTo.setText(savedToText)
binding.etTo.setText(savedToText, false)
}
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
@@ -537,7 +533,7 @@ class TransferFragment : Fragment() {
resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.etTo.setText("")
binding.etTo.setText("", false)
}
} else {
binding.tilTo.hint = getString(R.string.transfer_to)
@@ -551,7 +547,7 @@ class TransferFragment : Fragment() {
resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.etTo.setText("")
binding.etTo.setText("", false)
}
}
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
@@ -720,13 +716,12 @@ class TransferFragment : Fragment() {
}
private fun setupAccountLookup() {
binding.tilTo.setEndIconOnClickListener {
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
} else {
lookupAccount()
}
binding.tilTo.setEndIconOnClickListener { searchTo() }
binding.etTo.setOnEditorActionListener { _, actionId, _ ->
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
searchTo()
true
} else false
}
binding.btnClearToInfo.setOnClickListener {
@@ -764,6 +759,62 @@ class TransferFragment : Fragment() {
updateTransferButton()
}
}
setupContactDropdown()
}
private fun searchTo() {
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
} else {
lookupAccount()
}
}
/** Moves focus to the amount field and brings up the keyboard once a recipient is resolved. */
internal fun focusAmount() {
val et = binding.etAmount
// Posted so it runs after the To-row visibility changes and any closing picker sheet
et.post {
if (_binding == null) return@post
et.requestFocus()
et.setSelection(et.text?.length ?: 0)
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.showSoftInput(et, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
}
}
/** Live "search contacts as you type" dropdown on the To field (shown once 3+ chars are entered). */
private fun setupContactDropdown() {
val adapter = ContactDropdownAdapter(requireContext()) { selectedAccount?.bank == "MFAISA" }
contactDropdownAdapter = adapter
binding.etTo.setAdapter(adapter)
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
adapter.updateSource(contacts)
}
binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
prefillToDirectly(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
imageHash = contact.customerImgHash
)
selectDefaultSourceIfNone()
focusAmount()
}
}
/** Picks the user's default account as the source when a recipient is chosen before any source. */
private fun selectDefaultSourceIfNone() {
if (selectedAccount != null) return
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() ?: return
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum } ?: return
selectSourceAccount(defaultAcc)
}
private fun lookupAccount() {
@@ -801,6 +852,7 @@ class TransferFragment : Fragment() {
colorHex = matchedContact.bankColor,
imageHash = matchedContact.customerImgHash
)
focusAmount()
return
}
@@ -868,7 +920,8 @@ class TransferFragment : Fragment() {
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
resolvedDestCurrency = info.currency
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
.joinToString(" · ")
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
@@ -880,7 +933,7 @@ class TransferFragment : Fragment() {
showToCard(matchedAcc)
} else {
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
@@ -892,6 +945,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info)
focusAmount()
when {
matchedAcc?.profileImageHash != null ->
@@ -975,7 +1029,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
binding.etTo.setText(accountNumber)
binding.etTo.setText(accountNumber, false)
lookupAccount()
}
@@ -1512,7 +1566,7 @@ class TransferFragment : Fragment() {
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.etTo.setText("")
binding.etTo.setText("", false)
binding.tilTo.error = null
binding.tilAmount.error = null
}
@@ -1782,4 +1836,59 @@ class TransferFragment : Fragment() {
override fun convertResultToString(r: Any?) = ""
}
}
/** Filters [source] contacts by nickname/name/account number, shown once the query is 3+ chars. */
private inner class ContactDropdownAdapter(
private val context: Context,
private val isDisabled: () -> Boolean
) : BaseAdapter(), Filterable {
private var source: List<BankContact> = emptyList()
private var filtered: List<BankContact> = emptyList()
fun updateSource(contacts: List<BankContact>) {
source = contacts
}
fun getContact(position: Int): BankContact? = filtered.getOrNull(position)
override fun getCount() = filtered.size
override fun getItem(position: Int) = filtered[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val contact = filtered[position]
val b = if (convertView?.tag is ItemPickerRowBinding) {
convertView.tag as ItemPickerRowBinding
} else {
ItemPickerRowBinding.inflate(LayoutInflater.from(context), parent, false).also { it.root.tag = it }
}
b.tvPrimary.text = contact.benefNickName
b.tvSecondary.text = "${contact.benefBankName} · ${contact.benefAccount}"
b.tvBalance.visibility = View.GONE
b.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
b.ivIcon.setImageBitmap(makeInitialsBitmap(contact.benefNickName, contact.bankColor))
return b.root
}
override fun getFilter() = object : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint?.toString()?.trim().orEmpty()
val matches = if (isDisabled() || query.length < 3) emptyList() else source.filter {
it.benefNickName.contains(query, ignoreCase = true) ||
it.benefName.contains(query, ignoreCase = true) ||
it.benefAccount.contains(query, ignoreCase = true)
}.take(8)
return FilterResults().apply { values = matches; count = matches.size }
}
@Suppress("UNCHECKED_CAST")
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
filtered = results?.values as? List<BankContact> ?: emptyList()
notifyDataSetChanged()
}
override fun convertResultToString(r: Any?) = ""
}
}
}
@@ -194,5 +194,6 @@ class FahipayTransferHandler(
colorHex = "#FF6B00",
imageHash = null
)
fragment.focusAmount()
}
}
@@ -121,6 +121,7 @@ class MfaisaTransferHandler(
} else {
recipient = result
showResolvedRecipient(result)
fragment.focusAmount()
}
} catch (_: MfaisaRecipientNotFoundException) {
binding.tilTo.error = "No M-Faisa wallet found for this number"
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
class CredentialStore(context: Context) {
companion object {
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
}
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
private val keyAlias = "basedbank_credential_key"
private val transformation = "AES/GCM/NoPadding"
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
data class FahipayCredentials(val idCard: String, val password: String)
data class MfaisaCredentials(val msisdn: String, val pin: String)
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
fun getLoginOrder(): List<String> {
val all = getMibLoginIds().map { loginKey("mib", it) } +
getBmlLoginIds().map { loginKey("bml", it) } +
getFahipayLoginIds().map { loginKey("fahipay", it) } +
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
val saved = try {
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
return saved.filter { it in all } + all.filter { it !in saved }
}
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
fun loginRank(): (String) -> Int {
val order = getLoginOrder()
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
}
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
fun setLoginOrder(keys: List<String>) {
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
setMibLoginIds(idsFor("mib"))
setBmlLoginIds(idsFor("bml"))
setFahipayLoginIds(idsFor("fahipay"))
setMfaisaLoginIds(idsFor("mfaisa"))
}
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
fun getMibLoginIds(): List<String> {
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
fun setMibLoginIds(order: List<String>) {
if (order.toSet() != getMibLoginIds().toSet()) return
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (loginId !in ids) {
@@ -160,6 +201,11 @@ class CredentialStore(context: Context) {
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
fun setBmlLoginIds(order: List<String>) {
if (order.toSet() != getBmlLoginIds().toSet()) return
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addBmlLoginId(loginId: String) {
val ids = getBmlLoginIds().toMutableList()
if (loginId !in ids) {
@@ -316,6 +362,11 @@ class CredentialStore(context: Context) {
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
fun setFahipayLoginIds(order: List<String>) {
if (order.toSet() != getFahipayLoginIds().toSet()) return
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (loginId !in ids) {
@@ -473,6 +524,11 @@ class CredentialStore(context: Context) {
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
fun setMfaisaLoginIds(order: List<String>) {
if (order.toSet() != getMfaisaLoginIds().toSet()) return
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addMfaisaLoginId(loginId: String) {
val ids = getMfaisaLoginIds().toMutableList()
if (loginId !in ids) {
@@ -764,6 +820,15 @@ class CredentialStore(context: Context) {
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
// ── Per-account visibility (account numbers are globally unique) ─────────
/** Returns the set of account numbers the user has chosen to hide, across all logins. */
fun getHiddenAccountNumbers(): Set<String> =
prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet()
fun setHiddenAccountNumbers(accountNumbers: Set<String>) =
prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply()
// ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey {
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
</vector>
@@ -9,7 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="16dp"
android:clipToPadding="false">
<TextView
android:id="@+id/tvLoginsTitle"
@@ -20,10 +21,12 @@
android:layout_marginBottom="8dp"
android:visibility="gone" />
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
<LinearLayout
android:id="@+id/loginsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="-12dp"
android:orientation="vertical" />
<com.google.android.material.button.MaterialButton
@@ -148,12 +148,16 @@
app:endIconDrawable="@android:drawable/ic_menu_search"
app:endIconContentDescription="@string/transfer_lookup_account">
<com.google.android.material.textfield.TextInputEditText
<com.google.android.material.textfield.MaterialAutoCompleteTextView
android:id="@+id/etTo"
style="@style/Widget.Material3.AutoCompleteTextView.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textNoSuggestions"
android:maxLines="1" />
android:maxLines="1"
android:completionThreshold="3"
android:dropDownHeight="wrap_content"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
+1
View File
@@ -145,4 +145,5 @@
<!-- Connectivity banner -->
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
</resources>
+1
View File
@@ -391,4 +391,5 @@
<!-- Connectivity banner -->
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
<string name="connectivity_server_error">Connectivity issue with %s</string>
<string name="drag_to_reorder">Drag to reorder</string>
</resources>
+9
View File
@@ -155,6 +155,15 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
### Login Order
The user sets the order of their logins in [Settings → Logins](14-settings.md#login-order). It applies everywhere accounts are listed:
- `HomeViewModel.accounts` and `HomeViewModel.mibCards` sort themselves by `CredentialStore.loginRank()` on every `setValue` / `postValue`, so every observer (accounts list, transfer dropdown, contact picker "My accounts", PayMV QR, BML QR pay, transfer history, BML loans in Financing) gets the same order with no per-screen sorting. The sort is stable, so accounts from the same login keep the order the bank returned them in.
- Combined MIB + BML card lists (dashboard stack, [Cards](22-cards.md)) are sorted by `CardItem.loginTag`.
- [Financing](13-financing.md) places the MIB and BML sections by whichever bank's first login ranks higher.
- The [OTP Screen](10-otp-screen.md) sorts its entries by login.
---
## MIB Session KeepAlive
+1 -1
View File
@@ -30,7 +30,7 @@ Standard RFC 6238 TOTP:
## Supported Banks
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt:93-98`). Seeds are per-`loginId` in `CredentialStore`.
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
| Bank | Seed source | Card label |
|---|---|---|
+2
View File
@@ -8,6 +8,8 @@ Aggregates financing products across banks — MIB promotional deals and BML loa
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
MIB deals carry no login tag, so the two sections are ordered as blocks: MIB first if the first MIB login ranks above the first BML login in the user's [login order](00-app-overview.md#login-order), otherwise BML first. BML loans within their section follow `HomeViewModel.accounts`, which is already in login order.
---
## MIB Deals Section
+14
View File
@@ -33,6 +33,20 @@ Each profile entry shows:
- Profile image (circular avatar)
- Visibility toggle switch
### Login Order
All logins are shown as one list, across banks. When there is more than one login, each row gets a three-line handle (`ic_reorder_lines`) on the right; press and drag it to move the login up or down (`SettingsLoginsFragment.attachDragHandle()`). Tapping the rest of the row still opens the login details.
On drop the order is saved with `CredentialStore.setLoginOrder()`, which:
- writes the combined order to the `login_order` pref as a JSON array of login tags (`mib_<id>`, `bml_<id>`, `fahipay_<id>`, `mfaisa_<id>` — the same format as `BankAccount.loginTag`)
- rewrites each bank's own list (`mib_login_ids`, `bml_login_ids`, …) in the same relative order, so code that walks one bank's logins stays consistent
It then calls `HomeViewModel.resortByLoginOrder()` so already-open screens re-sort immediately.
`CredentialStore.getLoginOrder()` returns the effective order: saved tags that still exist, followed by any logins not yet ordered (e.g. newly added) in the default MIB → BML → Fahipay → M-Faisa order. `loginRank()` turns that into a sort key; unknown tags sort last.
See [Login Order](00-app-overview.md#login-order) for where the order is applied.
### Visibility Toggle
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
+1
View File
@@ -29,6 +29,7 @@ A horizontal `RecyclerView` with `LinearSnapHelper` that paginates BML cards + M
- BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"`
- MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`)
- Cards are sorted by the user's [login order](00-app-overview.md#login-order)
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
+1 -1
View File
@@ -29,7 +29,7 @@ A horizontal `RecyclerView` driven by `CardStackAdapter` with a `PagerSnapHelper
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
- MIB: `viewModel.mibCards`
The combined order is `bmlActive + mibActive + bmlInactive + mibInactive`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
Active cards come first, then inactive ones; within each group cards are sorted by the user's [login order](00-app-overview.md#login-order) via `CardItem.loginTag`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
### Card Art
+1 -1
View File
@@ -22,7 +22,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
| [14 — Settings](14-settings.md) | Settings hub: Logins, Appearance, Privacy & Security, Notifications, Storage, About |
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |