Compare commits
13
Commits
9e45e36c0a
...
v1.0.26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f7590654
|
||
|
|
a048be939c
|
||
|
|
102a986201
|
||
|
|
440db13a0f
|
||
|
|
0dc81fef62
|
||
|
|
7e52b510a1
|
||
|
|
0d7833af5c
|
||
|
|
d98dcfd407
|
||
|
|
3b55fa30a8
|
||
|
|
7f0f2707a1
|
||
|
|
117733b766
|
||
|
|
d769df6f6b
|
||
|
|
caf0db60da
|
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 26
|
||||
versionName = "1.0.25"
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -33,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
|
||||
@@ -530,6 +531,119 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -540,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 {
|
||||
@@ -582,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
|
||||
@@ -623,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)
|
||||
@@ -656,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,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 {
|
||||
@@ -727,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))
|
||||
@@ -774,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)
|
||||
@@ -817,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1044,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 {
|
||||
@@ -1094,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) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
.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) {
|
||||
@@ -1184,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.
|
||||
@@ -1239,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
|
||||
@@ -1261,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
|
||||
}
|
||||
|
||||
@@ -1269,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)
|
||||
@@ -1296,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"
|
||||
|
||||
@@ -820,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user