From caf0db60da9fb1b7f1a8e235cb1df5476b058257 Mon Sep 17 00:00:00 2001 From: WovenCoast Date: Mon, 21 Sep 2026 17:12:09 +0500 Subject: [PATCH] autocomplete with saved contacts in transfer page implements #49 --- .../sar/basedbank/ui/home/TransferFragment.kt | 97 +++++++++++++++++-- app/src/main/res/layout/fragment_transfer.xml | 7 +- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 754c5ea..bf2f125 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -40,9 +40,11 @@ import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.models.BankAccount +import sh.sar.basedbank.api.models.BankContact import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding +import sh.sar.basedbank.databinding.ItemPickerRowBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler @@ -92,6 +94,7 @@ class TransferFragment : Fragment() { private val dropdownProfileImageCache = mutableMapOf() private var accountDropdownAdapter: AccountDropdownAdapter? = null + private var contactDropdownAdapter: ContactDropdownAdapter? = null /** * Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP @@ -305,6 +308,9 @@ class TransferFragment : Fragment() { setupFromDropdown() setupAccountLookup() + // Contact search dropdown on the To field needs viewModel.contacts populated — + // otherwise it stays empty until the contacts tab or picker sheet is opened first. + (activity as? HomeActivity)?.loadAllContacts() viewModel.hideAmounts.observe(viewLifecycleOwner) { accountDropdownAdapter?.notifyDataSetChanged() @@ -327,7 +333,7 @@ class TransferFragment : Fragment() { // MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the // basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled. if (selectedAccount?.bank == "MFAISA") { - binding.etTo.setText(accountNumber) + binding.etTo.setText(accountNumber, false) mfaisaHandler().searchRecipient(accountNumber) return@setFragmentResultListener } @@ -408,7 +414,7 @@ class TransferFragment : Fragment() { binding.cardToInfo.visibility = View.VISIBLE if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null) } else if (savedToText.isNotEmpty()) { - binding.etTo.setText(savedToText) + binding.etTo.setText(savedToText, false) } if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount) if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks) @@ -537,7 +543,7 @@ class TransferFragment : Fragment() { resolvedToOwnAccount = null binding.cardToInfo.visibility = View.GONE binding.tilTo.visibility = View.VISIBLE - binding.etTo.setText("") + binding.etTo.setText("", false) } } else { binding.tilTo.hint = getString(R.string.transfer_to) @@ -551,7 +557,7 @@ class TransferFragment : Fragment() { resolvedToOwnAccount = null binding.cardToInfo.visibility = View.GONE binding.tilTo.visibility = View.VISIBLE - binding.etTo.setText("") + binding.etTo.setText("", false) } } // The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with @@ -764,6 +770,30 @@ class TransferFragment : Fragment() { updateTransferButton() } } + + setupContactDropdown() + } + + /** Live "search contacts as you type" dropdown on the To field (shown once 3+ chars are entered). */ + private fun setupContactDropdown() { + val adapter = ContactDropdownAdapter(requireContext()) { selectedAccount?.bank == "MFAISA" } + contactDropdownAdapter = adapter + binding.etTo.setAdapter(adapter) + + viewModel.contacts.observe(viewLifecycleOwner) { contacts -> + adapter.updateSource(contacts) + } + + binding.etTo.setOnItemClickListener { _, _, position, _ -> + val contact = adapter.getContact(position) ?: return@setOnItemClickListener + prefillToDirectly( + accountNumber = contact.benefAccount, + displayName = contact.benefNickName, + subtitle = "${contact.benefBankName} · ${contact.benefAccount}", + colorHex = contact.bankColor, + imageHash = contact.customerImgHash + ) + } } private fun lookupAccount() { @@ -975,7 +1005,7 @@ class TransferFragment : Fragment() { binding.btnPickContact.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE binding.tilTo.error = null - binding.etTo.setText(accountNumber) + binding.etTo.setText(accountNumber, false) lookupAccount() } @@ -1512,7 +1542,7 @@ class TransferFragment : Fragment() { binding.tilTo.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE - binding.etTo.setText("") + binding.etTo.setText("", false) binding.tilTo.error = null binding.tilAmount.error = null } @@ -1782,4 +1812,59 @@ class TransferFragment : Fragment() { override fun convertResultToString(r: Any?) = "" } } + + /** Filters [source] contacts by nickname/name/account number, shown once the query is 3+ chars. */ + private inner class ContactDropdownAdapter( + private val context: Context, + private val isDisabled: () -> Boolean + ) : BaseAdapter(), Filterable { + + private var source: List = emptyList() + private var filtered: List = emptyList() + + fun updateSource(contacts: List) { + source = contacts + } + + fun getContact(position: Int): BankContact? = filtered.getOrNull(position) + + override fun getCount() = filtered.size + override fun getItem(position: Int) = filtered[position] + override fun getItemId(position: Int) = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val contact = filtered[position] + val b = if (convertView?.tag is ItemPickerRowBinding) { + convertView.tag as ItemPickerRowBinding + } else { + ItemPickerRowBinding.inflate(LayoutInflater.from(context), parent, false).also { it.root.tag = it } + } + b.tvPrimary.text = contact.benefNickName + b.tvSecondary.text = "${contact.benefBankName} · ${contact.benefAccount}" + b.tvBalance.visibility = View.GONE + b.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + b.ivIcon.setImageBitmap(makeInitialsBitmap(contact.benefNickName, contact.bankColor)) + return b.root + } + + override fun getFilter() = object : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + val query = constraint?.toString()?.trim().orEmpty() + val matches = if (isDisabled() || query.length < 3) emptyList() else source.filter { + it.benefNickName.contains(query, ignoreCase = true) || + it.benefName.contains(query, ignoreCase = true) || + it.benefAccount.contains(query, ignoreCase = true) + }.take(8) + return FilterResults().apply { values = matches; count = matches.size } + } + + @Suppress("UNCHECKED_CAST") + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + filtered = results?.values as? List ?: emptyList() + notifyDataSetChanged() + } + + override fun convertResultToString(r: Any?) = "" + } + } } diff --git a/app/src/main/res/layout/fragment_transfer.xml b/app/src/main/res/layout/fragment_transfer.xml index 02cc49e..64ff153 100644 --- a/app/src/main/res/layout/fragment_transfer.xml +++ b/app/src/main/res/layout/fragment_transfer.xml @@ -148,12 +148,15 @@ app:endIconDrawable="@android:drawable/ic_menu_search" app:endIconContentDescription="@string/transfer_lookup_account"> - + android:maxLines="1" + android:completionThreshold="3" + android:dropDownHeight="wrap_content" /> -- 2.54.0