prep for reload/raastas part 2
Auto Tag on Version Change / check-version (push) Successful in 4s

This commit is contained in:
2026-10-02 19:12:21 +05:00
parent 877147959a
commit b05ee44715
13 changed files with 702 additions and 249 deletions
@@ -35,6 +35,8 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
@@ -47,11 +49,13 @@ 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.databinding.ItemTransferTypeBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
import sh.sar.basedbank.ui.home.transfer.TransferDraft
import sh.sar.basedbank.ui.home.transfer.TransferType
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser
@@ -497,6 +501,7 @@ class TransferFragment : Fragment() {
}
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
}
if (transferTypePending) showTransferTypePicker()
updateTransferButton()
}
@@ -539,6 +544,117 @@ class TransferFragment : Fragment() {
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
}
// ── Transfer type ─────────────────────────────────────────────────────────
/** The open "Transfer Type" picker, if any — dismissed when the options go away. */
private var transferTypeDialog: AlertDialog? = null
/**
* Offers [types] for [number]. A lone option is applied straight away; with more, the
* "Transfer Type" picker pops up with nothing preselected — the user has to pick, and Send
* stays disabled until they do. Either way the pick also switches the source to an account
* that can pay it (see [applyTransferType]).
*/
internal fun offerTransferTypes(number: String, types: List<TransferType>) {
draft.transferTypes = types
draft.transferTypeNumber = number
draft.transferType = null
types.singleOrNull()?.let { applyTransferType(it); return }
updateTransferButton()
showTransferTypePicker()
}
/**
* Pops up the offered transfer types. Before anything is picked the user must choose or
* cancel, and cancelling gives up on the number: the options are dropped and the "To" field
* is back for editing. Reopened to change a pick, cancelling just keeps the current one.
*/
private fun showTransferTypePicker() {
val types = draft.transferTypes
if (types.isEmpty() || transferTypeDialog?.isShowing == true) return
// Up to three tiles a row; fewer options just share the width
val grid = androidx.recyclerview.widget.RecyclerView(requireContext()).apply {
layoutManager = androidx.recyclerview.widget.GridLayoutManager(context, types.size.coerceIn(1, 3))
val pad = (20 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
clipToPadding = false
overScrollMode = View.OVER_SCROLL_NEVER
}
grid.adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter<androidx.recyclerview.widget.RecyclerView.ViewHolder>() {
override fun getItemCount() = types.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
object : androidx.recyclerview.widget.RecyclerView.ViewHolder(
ItemTransferTypeBinding.inflate(layoutInflater, parent, false).root) {}
override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) {
val tile = ItemTransferTypeBinding.bind(holder.itemView)
val type = types[position]
tile.ivIcon.setImageResource(type.iconRes)
val badge = type.badgeRes
tile.ivBadge.visibility = if (badge != null) View.VISIBLE else View.GONE
badge?.let { tile.ivBadge.setImageResource(it) }
tile.tvLabel.text = type.label
tile.tvSubtitle.text = type.subtitle
tile.root.setOnClickListener {
transferTypeDialog?.dismiss()
applyTransferType(type)
}
}
}
transferTypeDialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.transfer_type_for, draft.transferTypeNumber))
.setView(grid)
.setNegativeButton(android.R.string.cancel) { _, _ -> if (transferTypePending) clearRecipient() }
.setCancelable(!transferTypePending)
.setOnDismissListener { transferTypeDialog = null }
.show()
}
/** Forgets the offered types and closes the picker if it's up. */
internal fun resetTransferTypes() {
draft.transferTypes = emptyList()
draft.transferTypeNumber = ""
draft.transferType = null
fahipayHandler?.clearState()
transferTypeDialog?.dismiss()
updateTransferButton()
}
/** Options are on offer but the user hasn't picked one yet. */
private val transferTypePending: Boolean
get() = draft.transferTypes.isNotEmpty() && draft.transferType == null
/**
* Applies a picked transfer type: switches the source to one that can pay it, unless the
* selected one already can, then fills the recipient card.
*/
private fun applyTransferType(type: TransferType) {
draft.transferType = type
val number = draft.transferTypeNumber
val source = selectedAccount
if (source == null || !type.worksFrom(source)) {
val accounts = viewModel.accounts.value ?: emptyList()
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val pick = when (type) {
// The default account when it can do Favara, otherwise leave the user to choose
is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) }
is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom)
}
if (pick != null) selectSourceAccount(pick)
else {
clearSourceAccount()
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
}
}
when (type) {
is TransferType.Favara -> {
fahipayHandler?.clearState()
showFavaraRecipient(type.info)
}
is TransferType.Fahipay -> fahipayHandler().applyService(type, number)
}
updateTransferButton()
}
// ── Sensitive masking helpers ─────────────────────────────────────────────
private fun maskAmount(formatted: String): String {
@@ -568,6 +684,16 @@ class TransferFragment : Fragment() {
binding.actvFrom.setText("", false)
return@setOnItemClickListener
}
// A source that can't pay the picked transfer type: drop the pick and its
// recipient card, and ask again for the same number. A lone option would only
// switch the source straight back, so then the recipient goes altogether.
if (draft.transferType?.worksFrom(picked) == false) {
if (draft.transferTypes.size > 1) {
clearRecipient(keepTransferTypes = true)
draft.transferType = null
showTransferTypePicker()
} else clearRecipient()
}
selectedAccount = picked
updateAmountPrefix(picked)
showFromCard(picked)
@@ -826,28 +952,18 @@ class TransferFragment : Fragment() {
} else false
}
binding.btnClearToInfo.setOnClickListener {
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler().clearQrMerchant()
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null
fahipayHandler().clearState()
mfaisaHandler?.clearState()
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
// Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard)
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
updateTransferButton()
binding.btnClearToInfo.setOnClickListener { clearRecipient() }
// Tapping the recipient card changes the transfer type, when the lookup offered more than one
binding.cardToInfo.setOnClickListener {
if (draft.transferTypes.size > 1) showTransferTypePicker()
}
binding.etTo.addTextChangedListener {
binding.tilTo.error = null
// Options looked up for a different number no longer apply
val offeredFor = draft.transferTypeNumber
if (offeredFor.isNotEmpty() && AccountInputParser.normalize(it?.toString().orEmpty().trim()) != offeredFor)
resetTransferTypes()
if (binding.cardToInfo.visibility == View.VISIBLE) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
@@ -866,6 +982,32 @@ class TransferFragment : Fragment() {
setupContactDropdown()
}
/**
* Drops the resolved recipient and brings the "To" field back. The transfer type options go
* too unless [keepTransferTypes] — the source changed under a picked type, and the user
* should be able to choose again for the same number.
*/
private fun clearRecipient(keepTransferTypes: Boolean = false) {
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler().clearQrMerchant()
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null
fahipayHandler?.clearState()
if (!keepTransferTypes) resetTransferTypes()
mfaisaHandler?.clearState()
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
// Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard)
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
updateTransferButton()
}
/**
* Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source.
* Done in place: reopening the screen for it rebuilt the whole form and made the To row
@@ -969,6 +1111,15 @@ class TransferFragment : Fragment() {
}
private fun lookupAccount() {
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
// No source yet and a phone number: look it up every way it could be paid and let the
// user pick the transfer type, which then picks the source
if (selectedAccount == null && AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupPhoneForAnySource(accountNumber)
return
}
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
@@ -986,7 +1137,6 @@ class TransferFragment : Fragment() {
return
}
}
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
if (accountNumber.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return
@@ -1013,100 +1163,147 @@ class TransferFragment : Fragment() {
return
}
val mibSess = mibHandler.session
val bmlSess = bmlSessionFor(selectedAccount)
if (mibSess == null && bmlSess == null) {
if (mibHandler.session == null && bmlSessionFor(selectedAccount) == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val isBmlSource = selectedAccount?.bank == "BML"
val preferBml = selectedAccount?.bank == "BML"
startLookupLoading()
// Both banks can resolve a destination, so whichever the source belongs to gets first
// try and the other is the fallback. Either way the result lands in MibIpsAccountInfo.
viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null
val info = withContext(Dispatchers.IO) {
if (isBmlSource && bmlSess != null) {
val isMibDest = AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val bmlInfo = bmlHandler().validateDestination(bmlSess, accountNumber, verifyAsMib = isMibDest)
if (bmlInfo != null) {
// BML's MIB verify endpoint doesn't return the account's currency.
// Enrich via MIB lookup when a MIB session is available.
if (isMibDest && bmlInfo.currency.isBlank() && mibSess != null)
bmlInfo.copy(currency = mibHandler.lookupCurrency(bmlInfo.accountNumber))
else bmlInfo
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
outcome.info
}
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
if (outcome.info != null) {
outcome.info
} else {
val bmlInfo = bmlSess?.let {
bmlHandler().validateDestination(it, accountNumber, verifyAsMib = false)
}
if (bmlInfo != null) errorMsg = null
else if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
bmlInfo
}
val (info, errorMsg) = withContext(Dispatchers.IO) { lookupFavara(accountNumber, preferBml) }
stopLookupLoading()
if (info != null) showFavaraRecipient(info)
else Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
}
}
/**
* A phone number searched before any source is picked. Runs the Favara lookup (through MIB
* or BML, whichever is logged in) and, when the user has a Fahipay wallet, the Dhiraagu /
* Ooredoo carrier lookup — in parallel — and offers whatever came back as transfer types.
*/
private fun lookupPhoneForAnySource(number: String) {
val accounts = viewModel.accounts.value ?: emptyList()
val hasFahipay = accounts.any { it.bank == "FAHIPAY" }
val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null
if (!hasFavara && !hasFahipay) {
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
return
}
// Favara goes through the default account's bank first, as it would once that's the source
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML"
val fahipay = fahipayHandler()
resetTransferTypes()
startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch {
val (favara, fahipayTypes) = withContext(Dispatchers.IO) {
coroutineScope {
val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null }
val fahipayTypes = async { if (hasFahipay) fahipay.lookupServices(number) else emptyList() }
favara.await() to fahipayTypes.await()
}
}
stopLookupLoading()
if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
val matchedCont = contacts.firstOrNull { it.benefAccount == info.accountNumber }
val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B"
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
resolvedDestCurrency = info.currency
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
.joinToString(" · ")
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) {
showToCard(matchedAcc)
} else {
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
}
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info)
focusAmount()
when {
matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedCont?.customerImgHash != null ->
loadToPhoto(matchedCont.customerImgHash, isProfile = false)
}
} else {
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes
if (types.isEmpty()) {
Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show()
return@launch
}
offerTransferTypes(number, types)
}
}
/**
* Favara / IPS lookup of [accountNumber], returning the resolved recipient or the error to
* show. Both banks can resolve a destination, so [preferBml] decides which gets first try
* and the other is the fallback. Either way the result lands in MibIpsAccountInfo.
* Blocking — call from IO.
*/
private fun lookupFavara(accountNumber: String, preferBml: Boolean): Pair<MibIpsAccountInfo?, String?> {
val mibSess = mibHandler.session
val bmlSess = bmlSessionFor(selectedAccount)
var errorMsg: String? = null
val info = if (preferBml && bmlSess != null) {
val isMibDest = AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val bmlInfo = bmlHandler().validateDestination(bmlSess, accountNumber, verifyAsMib = isMibDest)
if (bmlInfo != null) {
// BML's MIB verify endpoint doesn't return the account's currency.
// Enrich via MIB lookup when a MIB session is available.
if (isMibDest && bmlInfo.currency.isBlank() && mibSess != null)
bmlInfo.copy(currency = mibHandler.lookupCurrency(bmlInfo.accountNumber))
else bmlInfo
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
outcome.info
}
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
if (outcome.info != null) {
outcome.info
} else {
val bmlInfo = bmlSess?.let {
bmlHandler().validateDestination(it, accountNumber, verifyAsMib = false)
}
if (bmlInfo != null) errorMsg = null
else if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
bmlInfo
}
}
return info to errorMsg
}
/** Fills the recipient card with a Favara / IPS lookup result. */
private fun showFavaraRecipient(info: MibIpsAccountInfo) {
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
val matchedCont = contacts.firstOrNull { it.benefAccount == info.accountNumber }
val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B"
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
resolvedDestCurrency = info.currency
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
.joinToString(" · ")
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) {
showToCard(matchedAcc)
} else {
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
}
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info)
focusAmount()
when {
matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedCont?.customerImgHash != null ->
loadToPhoto(matchedCont.customerImgHash, isProfile = false)
}
}
@@ -1696,7 +1893,7 @@ class TransferFragment : Fragment() {
if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0
val hasAll = selectedAccount != null && recipientReady && amount > 0 && !transferTypePending
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet()
val bankOffline = "NO_INTERNET" in errors ||
@@ -1726,7 +1923,7 @@ class TransferFragment : Fragment() {
resolvedBankName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null
fahipayHandler().clearState()
resetTransferTypes()
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
@@ -1827,6 +2024,10 @@ class TransferFragment : Fragment() {
bmlHandler = null
fahipayHandler = null
mfaisaHandler = null
// Re-shown from the draft by the next view if still unanswered
transferTypeDialog?.setOnDismissListener(null)
transferTypeDialog?.dismiss()
transferTypeDialog = null
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
qrLauncher = null
_binding = null
@@ -1,6 +1,6 @@
package sh.sar.basedbank.ui.home.transfer
import android.view.View
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -15,24 +15,28 @@ import sh.sar.basedbank.util.AccountInputParser
/**
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
* a given number; [label] names it in the recipient card, [destinationLabel] in the confirm
* dialog's "To" block.
* a given number; [label] names it in the "Transfer Type" picker and the recipient card,
* [destinationLabel] in the confirm dialog's "To" block.
*
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
* exhaustive `when`s over this enum will point at every site that needs updating.
*/
enum class FahipayService(val label: String, val destinationLabel: String) {
RAASTAS("Raastas", "Ooredoo · Raastas"),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
enum class FahipayService(
val label: String,
val destinationLabel: String,
@param:DrawableRes val iconRes: Int,
) {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo),
}
/**
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
* number into a set of payable services, the chip picker shown when more than one applies, and
* the selected service that the confirm dialog labels the destination with.
* number into a set of payable services, offering those in the fragment's "Transfer Type"
* picker, and the selected service that the confirm dialog labels the destination with.
*
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
@@ -75,10 +79,39 @@ class FahipayTransferHandler(
lookupCarrier(rawInput)
}
/** Clears the selected service and hides the chip picker. */
/** Forgets the selected service. The picker itself is the fragment's to reset. */
fun clearState() {
service = null
binding.layoutServiceSelector.visibility = View.INVISIBLE
}
/**
* Fills the recipient card for a picked Fahipay transfer type. The fragment has already
* switched the source to a Fahipay account.
*/
fun applyService(type: TransferType.Fahipay, number: String) {
service = type.service
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${type.label} · $number",
colorHex = "#FF6B00",
imageHash = null
)
fragment.focusAmount()
}
/**
* The Fahipay transfer types [number] can be paid with, from the carrier lookup. Dhiraagu is
* the only carrier that hands back an owner name. Blocking — call from IO.
*/
fun lookupServices(number: String): List<TransferType.Fahipay> {
val result = queryCarriers(number)
val ownerName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
return servicesFor(result).map { TransferType.Fahipay(it, ownerName) }
}
// ─── Carrier lookup ──────────────────────────────────────────────────────
@@ -89,24 +122,13 @@ class FahipayTransferHandler(
)
private fun lookupCarrier(number: String) {
fragment.resetTransferTypes()
fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { queryCarriers(number) }
val types = withContext(Dispatchers.IO) { lookupServices(number) }
fragment.stopLookupLoading()
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
val services = servicesFor(result)
if (services.isEmpty()) return@launch
// Only one option — auto-select, no chip UI needed
if (services.size == 1) {
selectService(services[0], number, dhiraaguName)
return@launch
}
// Multiple options (Ooredoo HYBRID) — show chips
showServiceChips(services, number, dhiraaguName)
if (types.isEmpty()) return@launch
fragment.offerTransferTypes(number, types)
}
}
@@ -143,58 +165,4 @@ class FahipayTransferHandler(
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
}
// ─── Service picker ──────────────────────────────────────────────────────
private fun showServiceChips(
services: List<FahipayService>,
number: String,
dhiraaguName: String?
) {
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
binding.layoutServiceSelector.visibility = View.VISIBLE
binding.chipGroupService.clearCheck()
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
// resolve their display name from saved contacts instead.
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
}
private fun bindChip(
chip: com.google.android.material.chip.Chip,
picked: FahipayService,
number: String,
ownerName: String?
) {
chip.setOnCheckedChangeListener { _, checked ->
if (checked) {
selectService(picked, number, ownerName)
binding.layoutServiceSelector.visibility = View.INVISIBLE
}
}
}
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
service = picked
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${picked.label} · $number",
colorHex = "#FF6B00",
imageHash = null
)
fragment.focusAmount()
}
}
@@ -50,6 +50,11 @@ class TransferDraft {
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
// Transfer type — options the "To" lookup offered for [transferTypeNumber], and the pick
var transferTypes: List<TransferType> = emptyList()
var transferTypeNumber = ""
var transferType: TransferType? = null
// Fahipay
var fahipayService: FahipayService? = null
}
@@ -0,0 +1,42 @@
package sh.sar.basedbank.ui.home.transfer
import androidx.annotation.DrawableRes
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.models.BankAccount
/**
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
* the "To" field. Picking one also decides the source account — see [worksFrom].
*
* Add new kinds (BML verified card, …) as subclasses; the exhaustive `when`s over this type
* point at every site that needs updating.
*/
sealed interface TransferType {
val label: String
/** Second line in the picker: who gets paid, and how. */
val subtitle: String
@get:DrawableRes val iconRes: Int
/** Small logo on the icon's bottom corner — the wallet that pays it — or null for none. */
@get:DrawableRes val badgeRes: Int? get() = null
/** Whether [account] can be the source for this transfer type. */
fun worksFrom(account: BankAccount): Boolean
/** A bank transfer to the account the Favara ID (phone number) resolved to. */
data class Favara(val info: MibIpsAccountInfo) : TransferType {
override val label get() = "Favara Transfer"
override val subtitle get() = info.accountName
override val iconRes get() = R.drawable.favara_logo
override fun worksFrom(account: BankAccount) = account.bank == "MIB" || account.bank == "BML"
}
/** A Fahipay payout (reload, Raastas, bill pay) to the phone number. */
data class Fahipay(val service: FahipayService, val ownerName: String?) : TransferType {
override val label get() = service.label
override val subtitle get() = listOfNotNull(ownerName, "via Fahipay").joinToString(" · ")
override val iconRes get() = service.iconRes
override val badgeRes get() = R.drawable.fahipay_logo
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
}
}