diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index b762899..947b654 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -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.filterVisibleAccounts(): List { 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.filterVisibleCards(): List { + 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) { + 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) { + 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? { + 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 { + bmlClient.checkProfile(session) + val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId) + app.bmlSessions[profile.profileId] = session + return accounts + } + + fun tryRefresh(): List { + 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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt index 2d0cf1a..d7c8566 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt @@ -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, + indent: Boolean + ): Pair { + 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, + hiddenAccounts: MutableSet + ): List> = + 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, + hiddenAccounts: MutableSet, + showDivider: Boolean + ): List> { + 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>, + hiddenAccounts: Set, + 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, hiddenAccounts: Set) { + 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>() 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>() 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>() 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() } } } diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt index ddad526..e3574d7 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -820,6 +820,15 @@ class CredentialStore(context: Context) { fun setHiddenMibProfileIds(loginId: String, ids: Set) = 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 = + prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet() + + fun setHiddenAccountNumbers(accountNumbers: Set) = + prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply() + // ── Crypto primitives ───────────────────────────────────────────────────── private fun getOrCreateKey(): SecretKey {