Compare commits

..

2 Commits

Author SHA1 Message Date
ahusan 4fe67aba6e contacts: paginate MIB contact loading + cache progressively
The old refresh path called MibContactsClient.fetchContacts() per
profile, which greedily walked every page and stuffed the union into
the contacts cache in one shot. A user with several hundred or several
thousand MIB beneficiaries paid for N round-trips before the screen
could render, and the cache write encrypted the whole blob each time.

Introduces a paginator that streams contacts on demand:

  * MibContactsClient.fetchContactsPage(session, start, count) — single
    page; fetchContacts() now layers on top for callers that want all
    pages.

  * MibContactsPaginator — walks the user's MIB profiles serially under
    the existing mibMutex, returning one page per nextPage() call.
    Categories are accumulated across calls.

  * HomeActivity.refreshContacts now requests just the first page
    (default 30 contacts) per login. Subsequent pages arrive via
    HomeActivity.loadMoreMibContacts(), which ContactsFragment fires
    from a scroll listener when the user is within 5 rows of the end
    of any tab. NEXT_PAGE_SIZE is 100 — matches the API's natural
    page size.

  * ContactsCache.appendContacts merges a new page into the cached
    list (dedupe by benefNo) instead of overwriting. The first page
    of a refresh still uses save() so deletions take effect.

  * HomeViewModel exposes mibContactsLoading / mibContactsHasMore
    so the fragment can show a loading footer (item_loading_footer)
    on each contact tab while a page is in flight.

  * ContactsAdapter grows a showLoadingFooter flag and a second view
    type for the footer row.

Search remains client-side over the loaded set — results may be
partial until the user keeps scrolling. The MIB API supports
server-side search via the `search` parameter but wiring it in is a
separate change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:27:17 +05:00
shihaam 5cba468781 debug builds has debug suffix and different launcher icon color 2026-05-28 17:11:55 +05:00
20 changed files with 348 additions and 395 deletions
@@ -115,11 +115,7 @@ class BasedBankApp : Application() {
override fun onCreate() {
super.onCreate()
// Only apply wallpaper-based dynamic colors in system theme mode.
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
}
DynamicColors.applyToActivitiesIfAvailable(this)
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
AppCompatDelegate.setDefaultNightMode(when (theme) {
@@ -21,7 +21,6 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
import sh.sar.basedbank.BasedBankApp
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
@@ -47,7 +46,6 @@ class LockActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater)
@@ -57,9 +55,6 @@ class LockActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
@@ -61,38 +61,51 @@ class MibContactsClient {
}
}
/** Fetch a single page of contacts. Returns the list and the server-reported total count.
* [start] is 1-based; [count] is the desired page size. */
fun fetchContactsPage(
session: MibSession,
start: Int,
count: Int
): Pair<List<MibBeneficiary>, Int> {
val end = start + count - 1
// Server uses 100-row pages internally for its own pagination; we mirror that
// by computing a page index that aligns with our start offset.
val page = ((start - 1) / 100) + 1
val body = FormBody.Builder()
.add("page", page.toString())
.add("search", "")
.add("searchCategoryId", "0")
.add("benefType", "A")
.add("sortBenef", "name")
.add("sortDir", "asc")
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/main")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return Pair(emptyList(), 0)
parseContacts(response.body?.string() ?: return Pair(emptyList(), 0))
}
}
/** Convenience: fetch all pages and return the union. Used by callers that don't paginate. */
fun fetchContacts(session: MibSession): List<MibBeneficiary> {
val all = mutableListOf<MibBeneficiary>()
var page = 1
var start = 1
val pageSize = 100
while (true) {
val start = (page - 1) * pageSize + 1
val end = page * pageSize
val body = FormBody.Builder()
.add("page", page.toString())
.add("search", "")
.add("searchCategoryId", "0")
.add("benefType", "A")
.add("sortBenef", "name")
.add("sortDir", "asc")
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/main")
.post(body)
.withSessionHeaders(session)
.build()
val (contacts, totalCount) = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return all
parseContacts(response.body?.string() ?: return all)
}
val (contacts, totalCount) = fetchContactsPage(session, start, pageSize)
all.addAll(contacts)
if (all.size >= totalCount || contacts.isEmpty()) break
page++
start += pageSize
}
return all
}
@@ -0,0 +1,80 @@
package sh.sar.basedbank.api.mib
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
/**
* Streams MIB contacts one page at a time, walking the user's MIB profiles in order.
*
* For multi-profile users we stay on the current profile until its server-reported
* `total_count` is reached, then advance to the next profile. Each profile switch is
* protected by the global `mibMutex` to keep the session state coherent.
*
* Categories are fetched lazily on the first page of each profile and accumulated
* across calls — the latest snapshot is exposed via [allCategories].
*/
class MibContactsPaginator(
private val app: BasedBankApp,
val loginId: String,
private val session: MibSession,
private val profiles: List<MibProfile>,
private val client: MibContactsClient = MibContactsClient()
) {
private var profileIndex = 0
private var nextStart = 1
private var totalForCurrentProfile = -1
private var categoriesFetchedForProfile = false
private val accumulatedCategories = LinkedHashMap<String, BankContactCategory>()
val allCategories: List<BankContactCategory>
get() = accumulatedCategories.values.toList()
fun hasMore(): Boolean = profileIndex < profiles.size
/** Fetch one page of up to [count] contacts. Returns the contacts contributed by
* this call (may be empty if a profile errored or returned no rows). Advances
* the internal cursor; subsequent calls continue from where this left off. */
suspend fun nextPage(count: Int): List<BankContact> = withContext(Dispatchers.IO) {
if (!hasMore()) return@withContext emptyList()
val profile = profiles[profileIndex]
val flow = app.mibFlowFor(loginId)
val contacts = app.mibMutex.withLock {
try {
flow.switchProfile(session, profile)
if (!categoriesFetchedForProfile) {
for (cat in client.fetchCategories(session)) {
accumulatedCategories.putIfAbsent(
cat.id,
BankContactCategory(cat.id, cat.categoryName, cat.numBenef)
)
}
categoriesFetchedForProfile = true
}
val (page, total) = client.fetchContactsPage(session, nextStart, count)
if (total > 0) totalForCurrentProfile = total
nextStart += page.size.coerceAtLeast(count)
page.map { it.copy(profileId = profile.profileId) }
} catch (_: Exception) {
// Bail on this profile and advance — keeps the paginator robust.
emptyList()
}
}
val exhausted =
(totalForCurrentProfile > 0 && nextStart - 1 >= totalForCurrentProfile) ||
contacts.isEmpty()
if (exhausted) {
profileIndex++
nextStart = 1
totalForCurrentProfile = -1
categoriesFetchedForProfile = false
}
contacts
}
}
@@ -8,6 +8,7 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
@@ -20,7 +21,7 @@ class ContactsAdapter(
private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<ContactDisplay> = emptyList()
@@ -28,6 +29,15 @@ class ContactsAdapter(
private var activeCategoryId: String? = null
private var searchQuery: String = ""
/** When true, a loading footer row is shown after the last contact. */
var showLoadingFooter: Boolean = false
set(value) {
if (field == value) return
field = value
if (value) notifyItemInserted(displayed.size)
else notifyItemRemoved(displayed.size)
}
fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts
applyFilter()
@@ -58,24 +68,32 @@ class ContactsAdapter(
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
override fun getItemViewType(position: Int): Int =
if (position == displayed.size && showLoadingFooter) VIEW_TYPE_FOOTER else VIEW_TYPE_CONTACT
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
if (viewType == VIEW_TYPE_FOOTER) {
val footer = LayoutInflater.from(parent.context)
.inflate(R.layout.item_loading_footer, parent, false)
return FooterHolder(footer)
}
val binding = ItemContactBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val holder = ViewHolder(binding)
binding.btnTransferContact.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
if (pos != RecyclerView.NO_POSITION && pos < displayed.size) onTransferClick(displayed[pos])
}
binding.btnEditContact.setOnClickListener {
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnDeleteContact.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
if (pos != RecyclerView.NO_POSITION && pos < displayed.size) onDeleteClick(displayed[pos])
}
binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
if (pos == RecyclerView.NO_POSITION || pos >= displayed.size) return@setOnLongClickListener false
val account = displayed[pos].accountNumber
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
@@ -86,15 +104,23 @@ class ContactsAdapter(
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is FooterHolder) return
val contact = displayed[position]
val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage)
(holder as ViewHolder).bind(contact, cachedImage)
}
override fun getItemCount() = displayed.size
override fun getItemCount() = displayed.size + if (showLoadingFooter) 1 else 0
private class FooterHolder(view: View) : RecyclerView.ViewHolder(view)
private companion object {
const val VIEW_TYPE_CONTACT = 0
const val VIEW_TYPE_FOOTER = 1
}
inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
@@ -76,6 +76,9 @@ class ContactsFragment : Fragment() {
fun updateImage(hash: String, bitmap: Bitmap) =
contactAdapters.forEach { it.updateImage(hash, bitmap) }
fun setLoadingFooter(visible: Boolean) =
contactAdapters.forEach { it.showLoadingFooter = visible }
inner class PageHolder(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
override fun getItemCount() = pages.size
@@ -93,6 +96,7 @@ class ContactsFragment : Fragment() {
val p80 = (65 * density).toInt()
setPadding(0, p4, 0, p80)
adapter = contactAdapters[viewType]
addOnScrollListener(scrollListener)
}
return PageHolder(rv)
}
@@ -100,6 +104,24 @@ class ContactsFragment : Fragment() {
override fun onBindViewHolder(holder: PageHolder, position: Int) {}
}
/** Triggers paginated MIB contact loading when the user scrolls within
* LOAD_MORE_THRESHOLD rows of the end of any tab. */
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0) return
val lm = rv.layoutManager as? LinearLayoutManager ?: return
val total = lm.itemCount
if (total == 0) return
if (lm.findLastVisibleItemPosition() >= total - LOAD_MORE_THRESHOLD) {
(activity as? HomeActivity)?.loadMoreMibContacts()
}
}
}
private companion object {
const val LOAD_MORE_THRESHOLD = 5
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
return binding.root
@@ -149,6 +171,15 @@ class ContactsFragment : Fragment() {
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
// Show a loading footer on each contact list while more MIB pages are arriving.
val updateFooter: () -> Unit = {
val loading = viewModel.mibContactsLoading.value == true
val hasMore = viewModel.mibContactsHasMore.value == true
pagerAdapter.setLoadingFooter(loading && hasMore)
}
viewModel.mibContactsLoading.observe(viewLifecycleOwner) { updateFooter() }
viewModel.mibContactsHasMore.observe(viewLifecycleOwner) { updateFooter() }
}
private fun attachMediator(pages: List<TabPage>) {
@@ -165,6 +196,10 @@ class ContactsFragment : Fragment() {
}
val savedPosition = binding.viewPager.currentItem
pagerAdapter = ContactsPagerAdapter(pages)
pagerAdapter.setLoadingFooter(
(viewModel.mibContactsLoading.value == true) &&
(viewModel.mibContactsHasMore.value == true)
)
binding.viewPager.adapter = pagerAdapter
attachMediator(pages)
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
@@ -57,6 +57,7 @@ import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibContactsPaginator
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfile
@@ -68,12 +69,15 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.ThemeHelper
class HomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityHomeBinding
private val viewModel: HomeViewModel by viewModels()
/** One paginator per MIB login. Reset on every refresh. */
private var mibContactPaginators: List<MibContactsPaginator> = emptyList()
private var mibLoadMoreInFlight = false
private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
@@ -107,7 +111,6 @@ class HomeActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater)
@@ -120,9 +123,6 @@ class HomeActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
// Auth guard: HomeActivity must only be reachable after LockActivity or fresh login.
// Using loadSecurityHash() (EncryptedSharedPreferences) instead of plain prefs so
// a rooted device cannot bypass this by editing security_method in plain prefs.
@@ -517,8 +517,9 @@ fun applyNavLabelVisibility() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.toolbar_menu, menu)
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
val eyeItem = menu.findItem(R.id.action_hide_amounts)
eyeItem?.isVisible = true
eyeItem?.isVisible = eyeEnabled
val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
return true
@@ -927,44 +928,78 @@ fun applyNavLabelVisibility() {
return result
}
/** Spin up (or replace) the paginator for this MIB login and fetch the first page.
* Subsequent pages arrive via [loadMoreMibContacts] in response to user scrolling. */
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient()
val paginator = MibContactsPaginator(
app = application as BasedBankApp,
loginId = loginId,
session = session,
profiles = profiles
)
// Replace any existing paginator for this login.
mibContactPaginators = mibContactPaginators.filter { it.loginId != loginId } + paginator
viewModel.mibContactsHasMore.value = mibContactPaginators.any { it.hasMore() }
lifecycleScope.launch {
try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>()
val seenCategories = mutableSetOf<String>()
val contacts = mutableListOf<BankContact>()
val categories = mutableListOf<BankContactCategory>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (cat in contactsClient.fetchCategories(session)) {
if (seenCategories.add(cat.id)) categories.add(cat)
}
for (contact in contactsClient.fetchContacts(session)) {
if (seenContacts.add(contact.benefNo))
contacts.add(contact.copy(profileId = profile.profileId))
}
} catch (_: Exception) { /* profile has no contacts access */ }
}
Pair(contacts, categories)
}
if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, store.getBmlLoginIds())
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
viewModel.contactCategories.postValue(allCategories + fahipayCategories)
}
} catch (_: Exception) { /* keep cached data */ }
viewModel.mibContactsLoading.postValue(true)
val firstPage = try {
paginator.nextPage(MIB_FIRST_PAGE_SIZE)
} catch (_: Exception) {
null
}
// Treat a successful refresh as authoritative: replace the cached MIB list
// with the first page, even if it shrinks (so deletions take effect).
if (firstPage != null) {
ContactsCache.save(this@HomeActivity, firstPage, paginator.allCategories)
publishContacts(paginator.allCategories)
}
viewModel.mibContactsLoading.postValue(false)
viewModel.mibContactsHasMore.postValue(mibContactPaginators.any { it.hasMore() })
}
}
/** Called by ContactsFragment when the user scrolls near the end of the loaded list. */
fun loadMoreMibContacts() {
if (mibLoadMoreInFlight) return
val paginator = mibContactPaginators.firstOrNull { it.hasMore() } ?: return
mibLoadMoreInFlight = true
lifecycleScope.launch {
viewModel.mibContactsLoading.postValue(true)
val page = try {
paginator.nextPage(MIB_NEXT_PAGE_SIZE)
} catch (_: Exception) {
emptyList()
}
if (page.isNotEmpty()) {
ContactsCache.appendContacts(this@HomeActivity, page, paginator.allCategories)
publishContacts(paginator.allCategories)
}
mibLoadMoreInFlight = false
viewModel.mibContactsLoading.postValue(false)
viewModel.mibContactsHasMore.postValue(mibContactPaginators.any { it.hasMore() })
}
}
/** Merge the current MIB cache with BML and Fahipay caches and post to the view model. */
private fun publishContacts(mibCategories: List<BankContactCategory>) {
val store = sh.sar.basedbank.util.CredentialStore(this)
val mibContacts = ContactsCache.loadContacts(this)
val bmlContacts = ContactsCache.loadBml(this, store.getBmlLoginIds())
val fahipayContacts = ContactsCache.loadFahipay(this)
val fahipayCategories = ContactsCache.loadFahipayCategories(this)
viewModel.contacts.postValue(
mergeContacts(mergeContacts(mibContacts, bmlContacts), fahipayContacts)
)
viewModel.contactCategories.postValue(mibCategories + fahipayCategories)
}
companion object {
private const val MIB_FIRST_PAGE_SIZE = 30
private const val MIB_NEXT_PAGE_SIZE = 100
}
fun refreshBalances(src: BankAccount) {
val app = application as BasedBankApp
lifecycleScope.launch {
@@ -23,6 +23,11 @@ class HomeViewModel : ViewModel() {
val contacts = MutableLiveData<List<BankContact>>(emptyList())
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
/** True while MIB contacts are paging in (initial load or scroll-triggered next page). */
val mibContactsLoading = MutableLiveData<Boolean>(false)
/** True while any MIB paginator still has pages to deliver. */
val mibContactsHasMore = MutableLiveData<Boolean>(false)
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
@@ -2,9 +2,7 @@ package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Color
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -12,7 +10,6 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat
@@ -21,11 +18,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import sh.sar.basedbank.util.ThemeHelper
import java.util.Collections
class SettingsAppearanceFragment : Fragment() {
@@ -109,45 +103,8 @@ class SettingsAppearanceFragment : Fragment() {
}
prefs.edit().putString("theme", key).apply()
AppCompatDelegate.setDefaultNightMode(mode)
updateAccentState(key == "system")
updatePitchBlackState(key == "dark")
}
// Pitch black
binding.switchPitchBlack.isChecked = prefs.getBoolean("pitch_black", false)
binding.switchPitchBlack.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("pitch_black", checked).apply()
requireActivity().recreate()
}
val isDark = prefs.getString("theme", "system") == "dark"
updatePitchBlackState(isDark)
// Accent color
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (savedPreset) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
binding.accentToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val preset = when (checkedId) {
R.id.btnAccentOrange -> ThemeHelper.PRESET_RED
R.id.btnAccentGreen -> ThemeHelper.PRESET_GREEN
R.id.btnAccentCustom -> ThemeHelper.PRESET_CUSTOM
else -> ThemeHelper.PRESET_BLUE
}
if (preset == ThemeHelper.PRESET_CUSTOM) {
showCustomColorPicker()
} else {
prefs.edit().putString("accent_preset", preset).apply()
requireActivity().recreate()
}
}
val isSystem = prefs.getString("theme", "system") == "system"
updateAccentState(isSystem)
// Language
val currentLocales = AppCompatDelegate.getApplicationLocales()
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
@@ -199,68 +156,6 @@ class SettingsAppearanceFragment : Fragment() {
slotAdapter.notifyDataSetChanged()
}
private fun updatePitchBlackState(isDark: Boolean) {
binding.rowPitchBlack.alpha = if (isDark) 1f else 0.38f
binding.switchPitchBlack.isEnabled = isDark
}
private fun updateAccentState(isSystem: Boolean) {
binding.sectionAccentColor.alpha = if (isSystem) 0.38f else 1f
for (i in 0 until binding.accentToggle.childCount) {
binding.accentToggle.getChildAt(i)?.isEnabled = !isSystem
}
}
private fun showCustomColorPicker() {
val ctx = requireContext()
val currentHex = prefs.getString("accent_custom_color", "") ?: ""
val inputLayout = TextInputLayout(ctx).apply {
hint = getString(R.string.accent_custom_hint)
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
}
val input = TextInputEditText(ctx).apply {
setText(currentHex)
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
setSingleLine(true)
}
inputLayout.addView(input)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.accent_custom_pick)
.setView(inputLayout)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(R.string.cancel) { _, _ -> revertAccentToggle() }
.setOnCancelListener { revertAccentToggle() }
.show()
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val raw = input.text.toString().trim()
val hex = if (raw.startsWith("#")) raw else "#$raw"
try {
Color.parseColor(hex)
prefs.edit()
.putString("accent_preset", ThemeHelper.PRESET_CUSTOM)
.putString("accent_custom_color", hex)
.apply()
dialog.dismiss()
requireActivity().recreate()
} catch (_: Exception) {
Toast.makeText(ctx, R.string.accent_invalid_color, Toast.LENGTH_SHORT).show()
}
}
}
private fun revertAccentToggle() {
val saved = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (saved) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
val isBottom = prefs.getBoolean("bottom_nav", false)
if (items === slots && !isBottom) return
@@ -273,7 +168,6 @@ class SettingsAppearanceFragment : Fragment() {
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.findViewById<TextView>(R.id.tvDescription).visibility = View.GONE
listLayout.addView(row)
}
}
@@ -86,6 +86,17 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer()
}
// Hide sensitive information (enables/disables the eye icon in toolbar)
val viewModel = (requireActivity() as HomeActivity).let {
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
}
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
if (!isChecked) viewModel.hideAmounts.value = false
requireActivity().invalidateOptionsMenu()
}
// Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots
@@ -9,14 +9,12 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.databinding.ActivityLoginBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is configured and the user hasn't unlocked this session,
@@ -36,8 +34,5 @@ class LoginActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
}
}
@@ -19,7 +19,6 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
@@ -28,7 +27,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is already configured, onboarding is complete. Redirect to lock screen
@@ -47,9 +45,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val originalBottomPadding = binding.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
@@ -51,6 +51,21 @@ object ContactsCache {
prefs.apply()
}
/** Merge new MIB contacts into the cached list, deduping by benefNo. Categories
* are replaced wholesale (the latest snapshot is canonical). Safe to call
* repeatedly as pages arrive. */
fun appendContacts(
context: Context,
newContacts: List<BankContact>,
categories: List<BankContactCategory>
) {
if (newContacts.isEmpty() && categories.isEmpty()) return
val existing = loadContacts(context)
val seen = existing.map { it.benefNo }.toHashSet()
val merged = existing + newContacts.filter { seen.add(it.benefNo) }
save(context, merged, categories)
}
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
@@ -1,78 +0,0 @@
package sh.sar.basedbank.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions
import sh.sar.basedbank.R
object ThemeHelper {
const val PRESET_BLUE = "blue"
const val PRESET_RED = "red"
const val PRESET_GREEN = "green"
const val PRESET_CUSTOM = "custom"
fun isSystemTheme(context: Context): Boolean =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("theme", "system") == "system"
fun getAccentPreset(context: Context): String =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("accent_preset", PRESET_BLUE) ?: PRESET_BLUE
fun getCustomColor(context: Context): Int? {
val hex = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("accent_custom_color", null) ?: return null
return try { Color.parseColor(hex) } catch (_: Exception) { null }
}
fun presetSeedColor(context: Context, preset: String): Int = when (preset) {
PRESET_RED -> Color.parseColor("#D32F2F")
PRESET_GREEN -> Color.parseColor("#4CAF50")
PRESET_CUSTOM -> getCustomColor(context) ?: Color.parseColor("#3F65AD")
else -> Color.parseColor("#3F65AD")
}
fun isPitchBlackEnabled(context: Context): Boolean =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("pitch_black", false)
/**
* Apply the user-chosen accent theme to the given activity.
* Must be called BEFORE super.onCreate() so window-level attributes
* (status bar color, etc.) are resolved from the correct overlay.
* Has no effect when the theme is set to "system" (dynamic colors are
* handled by BasedBankApp via DynamicColors.applyToActivitiesIfAvailable).
*/
fun applyAccent(activity: AppCompatActivity) {
if (isSystemTheme(activity)) return
val preset = getAccentPreset(activity)
val seed = presetSeedColor(activity, preset)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
bitmap.setPixel(0, 0, seed)
DynamicColors.applyToActivityIfAvailable(
activity,
DynamicColorsOptions.Builder().setContentBasedSource(bitmap).build()
)
} else {
// API < 31: apply a simple style overlay (partial palette, but functional)
val styleRes = when (preset) {
PRESET_RED -> R.style.ThemeOverlay_Accent_Orange
PRESET_GREEN -> R.style.ThemeOverlay_Accent_Green
else -> R.style.ThemeOverlay_Accent_Blue
}
activity.theme.applyStyle(styleRes, true)
}
val prefs = activity.getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isDark = prefs.getString("theme", "system") == "dark"
if (isDark && prefs.getBoolean("pitch_black", false)) {
activity.theme.applyStyle(R.style.ThemeOverlay_PitchBlack, true)
}
}
}
+1 -1
View File
@@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:fillColor="?attr/colorOnSurface"
android:pathData="M9.5,6.5v3h-3v-3H9.5zM11,5L5,5v5.5h6L11,5zM9.5,14.5v3h-3v-3H9.5zM11,13L5,13v5.5h6L11,13zM17.5,6.5v3h-3v-3H17.5zM19,5h-6v5.5h6L19,5zM13,13h1.5v1.5L13,14.5zM14.5,14.5L16,14.5L16,16h-1.5zM16,13h1.5v1.5L16,14.5zM13,16h1.5v1.5L13,17.5zM14.5,17.5L16,17.5L16,19h-1.5zM16,16h1.5v1.5L16,17.5zM17.5,14.5L19,14.5L19,16h-1.5zM17.5,17.5L19,17.5L19,19h-1.5z"/>
</vector>
@@ -325,6 +325,14 @@
android:layout_marginTop="16dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/nav_pay_with_card"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCards"
android:layout_width="match_parent"
@@ -350,6 +358,14 @@
android:paddingTop="8dp"
android:paddingBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -156,87 +156,6 @@
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Pitch black — enabled only in explicit dark mode -->
<LinearLayout
android:id="@+id/rowPitchBlack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_pitch_black"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchPitchBlack"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Accent color — always shown, disabled/grayed in system theme mode -->
<LinearLayout
android:id="@+id/sectionAccentColor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_accent_color"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/accentToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentBlue"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_blue" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentOrange"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_orange" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentGreen"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentCustom"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_custom" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -194,6 +194,44 @@
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowHideAmounts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchHideAmounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
-9
View File
@@ -148,15 +148,6 @@
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="settings_pitch_black">Pitch Black</string>
<string name="settings_accent_color">Accent Color</string>
<string name="accent_blue">Blue</string>
<string name="accent_orange">Red</string>
<string name="accent_green">Green</string>
<string name="accent_custom">Custom</string>
<string name="accent_custom_pick">Custom Accent Color</string>
<string name="accent_custom_hint">#RRGGBB hex color</string>
<string name="accent_invalid_color">Invalid color — enter a valid hex code</string>
<string name="language">Language</string>
<string name="lang_english">English</string>
<string name="lang_dhivehi">ދިވެހި</string>
-28
View File
@@ -6,32 +6,4 @@
<item name="colorSecondary">@color/seed_secondary</item>
<item name="android:windowSoftInputMode">adjustResize</item>
</style>
<!-- Accent overlays — applied on API < 31 as fallback when content-based dynamic colors unavailable -->
<style name="ThemeOverlay_Accent_Blue" parent="">
<item name="colorPrimary">#3F65AD</item>
<item name="colorSecondary">#9AD141</item>
</style>
<style name="ThemeOverlay_Accent_Orange" parent="">
<item name="colorPrimary">#D32F2F</item>
<item name="colorSecondary">#EF9A9A</item>
</style>
<style name="ThemeOverlay_Accent_Green" parent="">
<item name="colorPrimary">#4CAF50</item>
<item name="colorSecondary">#80CBC4</item>
</style>
<!-- Pitch black overlay — forces pure black surfaces for OLED displays -->
<style name="ThemeOverlay_PitchBlack" parent="">
<item name="android:colorBackground">#000000</item>
<item name="colorSurface">#000000</item>
<item name="colorSurfaceVariant">#0d0d0d</item>
<item name="colorSurfaceContainer">#0d0d0d</item>
<item name="colorSurfaceContainerLow">#000000</item>
<item name="colorSurfaceContainerLowest">#000000</item>
<item name="colorSurfaceContainerHigh">#1a1a1a</item>
<item name="colorSurfaceContainerHighest">#1a1a1a</item>
</style>
</resources>