This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user