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/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/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() }