This commit is contained in:
@@ -89,9 +89,6 @@ class TransferFragment : Fragment() {
|
||||
private var savedToColorHex = "#607D8B"
|
||||
private var savedToImageHash: String? = null
|
||||
|
||||
// M-Faisa QR merchant payment mode (set when the scanned QR is an M-Faisa qrCodeId)
|
||||
private var mfaisaQrInfo: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant? = null
|
||||
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
||||
@@ -108,12 +105,7 @@ class TransferFragment : Fragment() {
|
||||
binding = binding,
|
||||
viewModel = viewModel,
|
||||
currentSource = { selectedAccount },
|
||||
selectSource = { account ->
|
||||
selectedAccount = account
|
||||
updateAmountPrefix(account)
|
||||
showFromCard(account)
|
||||
updateTransferButton()
|
||||
},
|
||||
selectSource = ::selectSourceAccount,
|
||||
onStateChanged = { updateTransferButton() },
|
||||
onTransferSuccess = { receipt, avatar ->
|
||||
ReceiptStore.save(requireContext(), receipt)
|
||||
@@ -136,6 +128,8 @@ class TransferFragment : Fragment() {
|
||||
fragment = this,
|
||||
binding = binding,
|
||||
viewModel = viewModel,
|
||||
currentSource = { selectedAccount },
|
||||
selectSource = ::selectSourceAccount,
|
||||
onRecipientChanged = {
|
||||
// The handler resolved or cleared a recipient — keep the resolvedAccountNumber
|
||||
// mirror in sync so the shared `updateTransferButton()` / clearForm() logic works.
|
||||
@@ -195,7 +189,7 @@ class TransferFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } &&
|
||||
app.mfaisaSessions.isNotEmpty()) {
|
||||
lookupMfaisaQrMerchant(trimmedRaw)
|
||||
mfaisaHandler().lookupQrMerchant(trimmedRaw)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -321,7 +315,7 @@ class TransferFragment : Fragment() {
|
||||
if (accountNumber.startsWith("mfaisaqr:")) {
|
||||
// M-Faisa QR merchant recent — re-run the lookup so the merchant stays current
|
||||
// (price / status can change) and so the source auto-switch path is taken.
|
||||
lookupMfaisaQrMerchant(accountNumber.removePrefix("mfaisaqr:"))
|
||||
mfaisaHandler().lookupQrMerchant(accountNumber.removePrefix("mfaisaqr:"))
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
||||
@@ -416,93 +410,8 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||
* the amount if the QR is dynamic.
|
||||
*/
|
||||
private fun lookupMfaisaQrMerchant(qrCodeId: String) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
// Prefer the currently-selected MFAISA account's session; otherwise fall back to any session.
|
||||
val source = (selectedAccount?.takeIf { it.bank == "MFAISA" }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" })
|
||||
?: run {
|
||||
Toast.makeText(requireContext(), "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||
if (selectedAccount?.bank != "MFAISA") {
|
||||
selectedAccount = source
|
||||
updateAmountPrefix(source)
|
||||
showFromCard(source)
|
||||
}
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val merchant = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: return@withContext null
|
||||
try { sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||
catch (_: Exception) { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
if (merchant == null) {
|
||||
Toast.makeText(requireContext(), "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||
resetToFieldVisibility()
|
||||
return@launch
|
||||
}
|
||||
mfaisaQrInfo = merchant
|
||||
|
||||
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||
if (merchant.txnAmount.isNullOrBlank()) {
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||
displayName = merchant.merchantName,
|
||||
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||
colorHex = "#ED1C24",
|
||||
imageHash = null,
|
||||
isProfileImage = false,
|
||||
bank = "MFAISA"
|
||||
))
|
||||
}
|
||||
|
||||
// Show merchant in the "To" card — clear button is the only way to back out
|
||||
binding.tvToAccountName.text = merchant.merchantName
|
||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill + lock amount if the QR is dynamic
|
||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores the To-input row to its default state when a QR lookup fails. */
|
||||
private fun resetToFieldVisibility() {
|
||||
internal fun resetToFieldVisibility() {
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
@@ -556,7 +465,7 @@ class TransferFragment : Fragment() {
|
||||
return@setOnItemClickListener
|
||||
}
|
||||
}
|
||||
if (mfaisaQrInfo != null && picked.bank != "MFAISA") {
|
||||
if (mfaisaHandler().hasQrMerchant && picked.bank != "MFAISA") {
|
||||
Toast.makeText(requireContext(), "Unsupported for M-Faisa QR — select an M-Faisa account", Toast.LENGTH_SHORT).show()
|
||||
binding.actvFrom.setText("", false)
|
||||
return@setOnItemClickListener
|
||||
@@ -789,6 +698,17 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes [account] the source and repaints everything that depends on it. Handed to the
|
||||
* QR-capable handlers so a scanned merchant QR can switch the source to the right bank.
|
||||
*/
|
||||
private fun selectSourceAccount(account: BankAccount) {
|
||||
selectedAccount = account
|
||||
updateAmountPrefix(account)
|
||||
showFromCard(account)
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
private fun updateAmountPrefix(account: BankAccount) {
|
||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||
}
|
||||
@@ -805,13 +725,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
binding.btnClearToInfo.setOnClickListener {
|
||||
bmlHandler().clearQrMerchant()
|
||||
if (mfaisaQrInfo != null) {
|
||||
mfaisaQrInfo = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
mfaisaHandler().clearQrMerchant()
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
@@ -1118,42 +1032,9 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun initiateTransfer() {
|
||||
// M-Faisa merchant QR — same confirm popup as other transfers. The /initiateNewBuy +
|
||||
// /confirmNewBuy pair does NOT require OTP for wallet QR pay (2FARequired=NONE).
|
||||
mfaisaQrInfo?.let { merchant ->
|
||||
val src = selectedAccount ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (src.bank != "MFAISA") {
|
||||
Toast.makeText(requireContext(), "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
|
||||
val confirmView = buildTransferConfirmView(
|
||||
amountCurrency = merchant.currencyCode,
|
||||
amountValue = "%.2f".format(amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = merchant.merchantName,
|
||||
toNumber = merchant.merchantMsisdn,
|
||||
toDetail = "Ooredoo M-Faisa merchant"
|
||||
)
|
||||
showConfirmWithBiometric(
|
||||
title = getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${merchant.currencyCode} ${"%.2f".format(amount)} → ${merchant.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
showProcessingInDialog(dialog, frame)
|
||||
executeMfaisaQrPayment(src, merchant, amount, "%.2f".format(amount), remarks, dialog, frame)
|
||||
}
|
||||
)
|
||||
// M-Faisa merchant QR — same confirm popup as other transfers, no OTP.
|
||||
if (mfaisaHandler().hasQrMerchant) {
|
||||
mfaisaHandler().submitQrPayment()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1447,93 +1328,6 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeMfaisaQrPayment(
|
||||
src: BankAccount,
|
||||
merchant: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant,
|
||||
amount: Double,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||
val sourceMdn = "960${initialSession.msisdn}"
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outcome = withContext(Dispatchers.IO) {
|
||||
val client = sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient()
|
||||
try {
|
||||
val refId = try {
|
||||
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||
}
|
||||
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||
try {
|
||||
client.confirmPurchase(confirmSession, refId)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.confirmPurchase(fresh, refId)
|
||||
}
|
||||
Result.success(refId)
|
||||
} catch (e: Exception) {
|
||||
Result.failure<String>(e)
|
||||
}
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
|
||||
outcome.fold(
|
||||
onSuccess = { _ ->
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountStr,
|
||||
currency = merchant.currencyCode,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = merchant.merchantName,
|
||||
toAccount = merchant.merchantMsisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
mfaisaTransactionType = "Merchant payment",
|
||||
mfaisaFromName = src.accountBriefName,
|
||||
mfaisaFromMsisdn = src.accountNumber,
|
||||
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||
mfaisaTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
ReceiptStore.save(requireContext(), receipt)
|
||||
dialog.dismiss()
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, null))
|
||||
},
|
||||
onFailure = { e ->
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
val msg = when {
|
||||
e is java.io.IOException -> getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Payment failed"
|
||||
}
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||
@@ -1678,7 +1472,7 @@ class TransferFragment : Fragment() {
|
||||
private fun updateTransferButton() {
|
||||
if (bmlHandler().isOtpFlowActive) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
val recipientReady = bmlHandler().hasQrMerchant || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank()
|
||||
val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
||||
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||
@@ -1690,7 +1484,7 @@ class TransferFragment : Fragment() {
|
||||
internal fun clearForm() {
|
||||
bmlHandler().resetOtpState()
|
||||
mfaisaHandler?.clearState()
|
||||
mfaisaQrInfo = null
|
||||
mfaisaHandler?.clearQrMerchant()
|
||||
selectedAccount = null
|
||||
binding.actvFrom.setText("", false)
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -22,6 +23,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||
@@ -37,16 +39,25 @@ import sh.sar.basedbank.util.RecentsCache
|
||||
|
||||
/**
|
||||
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||
* initiate-with-OTP, and confirm-with-OTP. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment]
|
||||
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source.
|
||||
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||
* an M-Faisa merchant QR is loaded.
|
||||
*
|
||||
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class MfaisaTransferHandler(
|
||||
private val fragment: Fragment,
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||
private val onRecipientChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||
@@ -55,11 +66,16 @@ class MfaisaTransferHandler(
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient? = null
|
||||
private set
|
||||
|
||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||
private set
|
||||
|
||||
private var lookupInFlight = false
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
@@ -80,7 +96,7 @@ class MfaisaTransferHandler(
|
||||
}
|
||||
binding.tilTo.error = null
|
||||
|
||||
val source = currentSource() ?: return
|
||||
val source = mfaisaSource() ?: return
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -122,7 +138,7 @@ class MfaisaTransferHandler(
|
||||
|
||||
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||
fun submit() {
|
||||
val source = currentSource() ?: return
|
||||
val source = mfaisaSource() ?: return
|
||||
val r = recipient ?: run {
|
||||
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -153,20 +169,232 @@ class MfaisaTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||
|
||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||
fun clearState() {
|
||||
recipient = null
|
||||
lookupInFlight = false
|
||||
}
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||
* the amount if the QR is dynamic.
|
||||
*/
|
||||
fun lookupQrMerchant(qrCodeId: String) {
|
||||
val source = mfaisaSource() ?: run {
|
||||
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val merchant = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: return@withContext null
|
||||
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||
catch (_: Exception) { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
if (merchant == null) {
|
||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||
fragment.resetToFieldVisibility()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = merchant
|
||||
|
||||
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||
if (merchant.txnAmount.isNullOrBlank()) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||
displayName = merchant.merchantName,
|
||||
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||
colorHex = "#ED1C24",
|
||||
imageHash = null,
|
||||
isProfileImage = false,
|
||||
bank = "MFAISA"
|
||||
))
|
||||
}
|
||||
|
||||
// Show merchant in the "To" card — clear button is the only way to back out
|
||||
binding.tvToAccountName.text = merchant.merchantName
|
||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill + lock amount if the QR is dynamic
|
||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
onRecipientChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||
*/
|
||||
fun submitQrPayment() {
|
||||
val merchant = qrInfo ?: return
|
||||
val src = currentSource() ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (src.bank != "MFAISA") {
|
||||
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
val amountValue = "%.2f".format(amount)
|
||||
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = merchant.currencyCode,
|
||||
amountValue = amountValue,
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = merchant.merchantName,
|
||||
toNumber = merchant.merchantMsisdn,
|
||||
toDetail = "Ooredoo M-Faisa merchant"
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeQrPayment(
|
||||
src: BankAccount,
|
||||
merchant: MfaisaQrPayClient.QrMerchant,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
dialog: AlertDialog
|
||||
) {
|
||||
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||
val sourceMdn = "960${initialSession.msisdn}"
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outcome = withContext(Dispatchers.IO) {
|
||||
val client = MfaisaQrPayClient()
|
||||
try {
|
||||
val refId = try {
|
||||
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||
}
|
||||
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||
try {
|
||||
client.confirmPurchase(confirmSession, refId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.confirmPurchase(fresh, refId)
|
||||
}
|
||||
Result.success(refId)
|
||||
} catch (e: Exception) {
|
||||
Result.failure<String>(e)
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
|
||||
outcome.fold(
|
||||
onSuccess = { _ ->
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountStr,
|
||||
currency = merchant.currencyCode,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = merchant.merchantName,
|
||||
toAccount = merchant.merchantMsisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
mfaisaTransactionType = "Merchant payment",
|
||||
mfaisaFromName = src.accountBriefName,
|
||||
mfaisaFromMsisdn = src.accountNumber,
|
||||
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||
mfaisaTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
dialog.dismiss()
|
||||
onTransferSuccess(receipt, null)
|
||||
},
|
||||
onFailure = { e ->
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
showError(e, fallback = "Payment failed")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun currentSource(): BankAccount? =
|
||||
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable
|
||||
|
||||
private fun sourceAccountNumberFromCard(): String =
|
||||
binding.tvFromAccountNumber.text?.toString().orEmpty()
|
||||
/**
|
||||
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||
* the source away from another bank.
|
||||
*/
|
||||
private fun mfaisaSource(): BankAccount? =
|
||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||
|
||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||
@@ -241,7 +469,6 @@ class MfaisaTransferHandler(
|
||||
refId: String,
|
||||
errorMsg: String?
|
||||
) {
|
||||
val tf = fragment as? TransferFragment ?: return
|
||||
val view = fragment.view ?: return
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val colorMuted = MaterialColors.getColor(
|
||||
@@ -250,7 +477,7 @@ class MfaisaTransferHandler(
|
||||
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||
|
||||
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||
val confirmView = tf.buildTransferConfirmView(
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = "MVR",
|
||||
amountValue = amountValue,
|
||||
fromName = source.accountBriefName,
|
||||
@@ -415,11 +642,11 @@ class MfaisaTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(e: Exception) {
|
||||
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Transfer failed"
|
||||
else -> fallback
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user