diff --git a/app/src/debug/res/drawable/ic_launcher_background.xml b/app/src/debug/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..4cb08a7 --- /dev/null +++ b/app/src/debug/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..e1ab9be --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Thijooree Debug + diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt index d685eb1..c87a887 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt @@ -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, 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 { val all = mutableListOf() - 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 } diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsPaginator.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsPaginator.kt new file mode 100644 index 0000000..d8b805e --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsPaginator.kt @@ -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, + 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() + val allCategories: List + 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 = 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 + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt index 957c1bb..99120de 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt @@ -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() { +) : RecyclerView.Adapter() { private var allContacts: List = emptyList() private var displayed: List = 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) { 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) { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt index fb0897a..32cdf66 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt @@ -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) { @@ -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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt index c33bf9f..ba2d96d 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt @@ -126,6 +126,12 @@ class DashboardFragment : Fragment() { private fun refreshQuickActions() { val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) + val isBottom = prefs.getBoolean("bottom_nav", false) + if (isBottom) { + binding.buttonBar.visibility = View.GONE + return + } + binding.buttonBar.visibility = View.VISIBLE val ids = NavCustomization.getQuickActions(prefs) listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn -> val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] } 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 96219fe..1acd462 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 @@ -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 @@ -73,6 +74,10 @@ 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 = emptyList() + private var mibLoadMoreInFlight = false private lateinit var toggle: ActionBarDrawerToggle private var suppressBottomNavCallback = false @@ -923,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) { 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() - val seenCategories = mutableSetOf() - val contacts = mutableListOf() - val categories = mutableListOf() - 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) { + 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 { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt index 298eebc..24fc2bb 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt @@ -23,6 +23,11 @@ class HomeViewModel : ViewModel() { val contacts = MutableLiveData>(emptyList()) val contactCategories = MutableLiveData>(emptyList()) + /** True while MIB contacts are paging in (initial load or scroll-triggered next page). */ + val mibContactsLoading = MutableLiveData(false) + /** True while any MIB paginator still has pages to deliver. */ + val mibContactsHasMore = MutableLiveData(false) + data class BmlLimitsData(val userName: String, val limits: List) val bmlLimits = MutableLiveData>(emptyList()) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt index 778f3d9..0579c26 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt @@ -54,19 +54,29 @@ class SettingsAppearanceFragment : Fragment() { // Quick actions quickActions.clear() quickActions.addAll(NavCustomization.getQuickActions(prefs)) - quickActionAdapter = NavItemAdapter(quickActions) { - NavCustomization.saveQuickActions(prefs, quickActions) + quickActionAdapter = NavItemAdapter( + items = quickActions, + onSave = { NavCustomization.saveQuickActions(prefs, quickActions) }, + isEnabled = { !prefs.getBoolean("bottom_nav", false) } + ) + setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) { + !prefs.getBoolean("bottom_nav", false) } - setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) // Bottom bar shortcuts slots.clear() slots.addAll(NavCustomization.getSlots(prefs)) - slotAdapter = NavItemAdapter(slots) { - NavCustomization.saveSlots(prefs, slots) - (activity as? HomeActivity)?.rebuildBottomNav(prefs) + slotAdapter = NavItemAdapter( + items = slots, + onSave = { + NavCustomization.saveSlots(prefs, slots) + (activity as? HomeActivity)?.rebuildBottomNav(prefs) + }, + isEnabled = { prefs.getBoolean("bottom_nav", false) } + ) + setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) { + prefs.getBoolean("bottom_nav", false) } - setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) // Show labels toggle val showLabels = prefs.getBoolean("bottom_nav_show_labels", true) binding.switchShowLabels.isChecked = showLabels @@ -109,13 +119,18 @@ class SettingsAppearanceFragment : Fragment() { private fun setupNavItemRecyclerView( rv: RecyclerView, adapter: NavItemAdapter, - items: MutableList + items: MutableList, + isEnabled: () -> Boolean ) { rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) rv.adapter = adapter ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.START or ItemTouchHelper.END, 0 ) { + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + if (!isEnabled()) return 0 + return super.getMovementFlags(recyclerView, viewHolder) + } override fun onMove( rv: RecyclerView, from: RecyclerView.ViewHolder, @@ -134,11 +149,17 @@ class SettingsAppearanceFragment : Fragment() { private fun updateShortcutsVisibility() { val isBottom = prefs.getBoolean("bottom_nav", false) + binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f + binding.switchShowLabels.isClickable = isBottom + quickActionAdapter.notifyDataSetChanged() + slotAdapter.notifyDataSetChanged() } private fun showItemPicker(items: MutableList, slotIndex: Int, adapter: NavItemAdapter) { - if (items === slots && !prefs.getBoolean("bottom_nav", false)) return + val isBottom = prefs.getBoolean("bottom_nav", false) + if (items === slots && !isBottom) return + if (items === quickActions && isBottom) return val ctx = requireContext() val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet() val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds } @@ -169,7 +190,8 @@ class SettingsAppearanceFragment : Fragment() { private inner class NavItemAdapter( val items: MutableList, - val onSave: () -> Unit + val onSave: () -> Unit, + val isEnabled: () -> Boolean = { true } ) : RecyclerView.Adapter() { inner class VH(view: View) : RecyclerView.ViewHolder(view) { @@ -191,7 +213,12 @@ class SettingsAppearanceFragment : Fragment() { val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return holder.ivNavIcon.setImageResource(def.iconRes) holder.tvNavLabel.setText(def.titleRes) - holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) } + val enabled = isEnabled() + holder.itemView.setOnClickListener( + if (enabled) View.OnClickListener { showItemPicker(items, holder.adapterPosition, this) } + else null + ) + holder.itemView.isClickable = enabled } } diff --git a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt index 148cd38..562ef4f 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt @@ -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, + categories: List + ) { + 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() } diff --git a/app/src/main/res/layout/fragment_settings_appearance.xml b/app/src/main/res/layout/fragment_settings_appearance.xml index 6cddf24..12d6416 100644 --- a/app/src/main/res/layout/fragment_settings_appearance.xml +++ b/app/src/main/res/layout/fragment_settings_appearance.xml @@ -45,22 +45,30 @@ - - - - + + android:orientation="vertical" + android:layout_marginBottom="16dp"> + + + + + +