From ca6ecc42834832538f102bfb954ec61ecb209335 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Mon, 21 Sep 2026 07:41:36 +0500 Subject: [PATCH] split fahipay transfer --- .../sar/basedbank/ui/home/TransferFragment.kt | 132 ++---------- .../home/transfer/FahipayTransferHandler.kt | 198 ++++++++++++++++++ 2 files changed, 212 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index cb11860..f4375b5 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -39,14 +39,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R -import sh.sar.basedbank.api.dhiraagu.DhiraaguClient -import sh.sar.basedbank.api.fahipay.OoredooClient import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding 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.util.AccountListParser @@ -82,10 +81,6 @@ class TransferFragment : Fragment() { private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown private var resolvedToOwnAccount: BankAccount? = null - // Selected Fahipay service when source is Fahipay and destination is a phone number - // Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL" - private var selectedFahipayService: String? = null - // Form state preserved across view destroy/create when the fragment instance is cached private var savedAmount = "" private var savedRemarks = "" @@ -129,6 +124,11 @@ class TransferFragment : Fragment() { } ).also { bmlHandler = it } + /** Lazy: created the first time a Fahipay source needs a recipient lookup. */ + private var fahipayHandler: FahipayTransferHandler? = null + private fun fahipayHandler(): FahipayTransferHandler = + fahipayHandler ?: FahipayTransferHandler(this, binding, viewModel).also { fahipayHandler = it } + /** Lazy: created the first time the user selects an MFAISA source account. */ private var mfaisaHandler: MfaisaTransferHandler? = null private fun mfaisaHandler(): MfaisaTransferHandler = @@ -509,7 +509,7 @@ class TransferFragment : Fragment() { binding.btnScanQr.visibility = View.VISIBLE } - private fun startLookupLoading() { + internal fun startLookupLoading() { val spinner = CircularProgressDrawable(requireContext()).apply { setStyle(CircularProgressDrawable.DEFAULT) setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor( @@ -520,7 +520,7 @@ class TransferFragment : Fragment() { binding.tilTo.isEnabled = false } - private fun stopLookupLoading() { + internal fun stopLookupLoading() { binding.tilTo.isEnabled = true binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search) } @@ -816,10 +816,9 @@ class TransferFragment : Fragment() { resolvedRecipientName = "" resolvedDestCurrency = "" resolvedToOwnAccount = null - selectedFahipayService = null + fahipayHandler().clearState() mfaisaHandler?.clearState() binding.cardToInfo.visibility = View.GONE - binding.layoutServiceSelector.visibility = View.INVISIBLE binding.tilTo.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE @@ -887,11 +886,7 @@ class TransferFragment : Fragment() { // Fahipay source: only phone numbers are supported if (selectedAccount?.bank == "FAHIPAY") { - if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) { - lookupFahipayTarget(accountNumber) - } else { - binding.tilTo.error = getString(R.string.transfer_fahipay_phone_only) - } + fahipayHandler().lookupRecipient(accountNumber) return } @@ -990,100 +985,7 @@ class TransferFragment : Fragment() { } } - private fun lookupFahipayTarget(number: String) { - startLookupLoading() - viewLifecycleOwner.lifecycleScope.launch { - data class LookupResult( - val dhiraagu: DhiraaguClient.Result, - val ooredoo: OoredooClient.CustType - ) - val result = withContext(Dispatchers.IO) { - if (number.startsWith("7")) { - // Dhiraagu first, fall back to Ooredoo - val d = try { DhiraaguClient().validateNumber(number) } - catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) } - val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) - try { OoredooClient().validateNumber(number) } - catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED } - else OoredooClient.CustType.UNSUPPORTED - LookupResult(d, o) - } else { - // Ooredoo first, fall back to Dhiraagu - val o = try { OoredooClient().validateNumber(number) } - catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED } - val d = if (o == OoredooClient.CustType.UNSUPPORTED) - try { DhiraaguClient().validateNumber(number) } - catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) } - else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) - LookupResult(d, o) - } - } - stopLookupLoading() - - val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() } - - // Collect all applicable services - val services = buildList { - if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add("DHIRAAGU_RELOAD") - if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add("DHIRAAGU_BILL") - if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add("RAASTAS") - if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add("OOREDOO_BILL") - } - - if (services.isEmpty()) return@launch - - // Only one option — auto-select, no chip UI needed - if (services.size == 1) { - selectFahipayService(services[0], number, dhiraaguName) - return@launch - } - - // Multiple options (Ooredoo HYBRID) — show chips - binding.chipDhiraaguReload.visibility = if ("DHIRAAGU_RELOAD" in services) View.VISIBLE else View.GONE - binding.chipDhiraaguBill.visibility = if ("DHIRAAGU_BILL" in services) View.VISIBLE else View.GONE - binding.chipRaastas.visibility = if ("RAASTAS" in services) View.VISIBLE else View.GONE - binding.chipOoredooBill.visibility = if ("OOREDOO_BILL" in services) View.VISIBLE else View.GONE - binding.layoutServiceSelector.visibility = View.VISIBLE - binding.chipGroupService.clearCheck() - - binding.chipDhiraaguReload.setOnCheckedChangeListener { _, checked -> - if (checked) { selectFahipayService("DHIRAAGU_RELOAD", number, dhiraaguName); binding.layoutServiceSelector.visibility = View.INVISIBLE } - } - binding.chipDhiraaguBill.setOnCheckedChangeListener { _, checked -> - if (checked) { selectFahipayService("DHIRAAGU_BILL", number, dhiraaguName); binding.layoutServiceSelector.visibility = View.INVISIBLE } - } - binding.chipRaastas.setOnCheckedChangeListener { _, checked -> - if (checked) { selectFahipayService("RAASTAS", number); binding.layoutServiceSelector.visibility = View.INVISIBLE } - } - binding.chipOoredooBill.setOnCheckedChangeListener { _, checked -> - if (checked) { selectFahipayService("OOREDOO_BILL", number); binding.layoutServiceSelector.visibility = View.INVISIBLE } - } - } - } - - private fun selectFahipayService(service: String, number: String, ownerName: String? = null) { - selectedFahipayService = service - val contacts = viewModel.contacts.value ?: emptyList() - val displayName = ownerName - ?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName - ?: number - val serviceLabel = when (service) { - "RAASTAS" -> "Raastas" - "OOREDOO_BILL" -> "Ooredoo Bill Pay" - "DHIRAAGU_RELOAD" -> "Dhiraagu Reload" - "DHIRAAGU_BILL" -> "Dhiraagu Bill Pay" - else -> service - } - prefillToDirectly( - accountNumber = number, - displayName = displayName, - subtitle = "$serviceLabel · $number", - colorHex = "#FF6B00", - imageHash = null - ) - } - - private fun prefillToDirectly( + internal fun prefillToDirectly( accountNumber: String, displayName: String, subtitle: String, @@ -1403,13 +1305,7 @@ class TransferFragment : Fragment() { bankNameCapture.equals("MADVMVMV", ignoreCase = true) -> "MIB" bankNameCapture.isNotBlank() -> bankNameCapture isDestMib -> "MIB" - else -> when (selectedFahipayService) { - "RAASTAS" -> "Ooredoo · Raastas" - "OOREDOO_BILL" -> "Ooredoo · Bill Pay" - "DHIRAAGU_RELOAD" -> "Dhiraagu · Reload" - "DHIRAAGU_BILL" -> "Dhiraagu · Bill Pay" - else -> "" - } + else -> fahipayHandler().destinationLabel } val toDetail = listOfNotNull(toBankLabel.ifBlank { null }, toTypeLabel?.ifBlank { null }).joinToString(" · ") @@ -1810,9 +1706,8 @@ class TransferFragment : Fragment() { resolvedBankName = "" resolvedDestCurrency = "" resolvedToOwnAccount = null - selectedFahipayService = null + fahipayHandler().clearState() binding.cardToInfo.visibility = View.GONE - binding.chipGroupService.visibility = View.GONE binding.tilTo.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE @@ -1909,6 +1804,7 @@ class TransferFragment : Fragment() { // Clearing also resets any in-progress OTP flow, which cannot sensibly resume. bmlHandler?.clearState() bmlHandler = null + fahipayHandler = null mfaisaHandler?.clearState() mfaisaHandler = null // Unregistered automatically with viewLifecycleOwner; drop the stale handle. diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt new file mode 100644 index 0000000..ba29b08 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt @@ -0,0 +1,198 @@ +package sh.sar.basedbank.ui.home.transfer + +import android.view.View +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.R +import sh.sar.basedbank.api.dhiraagu.DhiraaguClient +import sh.sar.basedbank.api.fahipay.OoredooClient +import sh.sar.basedbank.databinding.FragmentTransferBinding +import sh.sar.basedbank.ui.home.HomeViewModel +import sh.sar.basedbank.ui.home.TransferFragment +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. + * + * 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"), +} + +/** + * 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. + * + * Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm + * dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific. + * + * **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of + * `initiateTransfer`, which signs the request with a MIB session. When the real payout API is + * wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the + * other handlers. + * + * Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] + * (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated. + */ +class FahipayTransferHandler( + private val fragment: TransferFragment, + private val binding: FragmentTransferBinding, + private val viewModel: HomeViewModel, +) { + + private val ctx get() = fragment.requireContext() + + /** The service picked for the current recipient; null until a lookup resolves one. */ + var service: FahipayService? = null + private set + + /** How the confirm dialog names the destination, or "" when nothing is selected. */ + val destinationLabel: String get() = service?.destinationLabel.orEmpty() + + // ─── Public API the fragment calls ─────────────────────────────────────── + + /** + * Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything + * else is rejected inline on the "To" field. + */ + fun lookupRecipient(rawInput: String) { + if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) { + binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only) + return + } + lookupCarrier(rawInput) + } + + /** Clears the selected service and hides the chip picker. */ + fun clearState() { + service = null + binding.layoutServiceSelector.visibility = View.INVISIBLE + } + + // ─── Carrier lookup ────────────────────────────────────────────────────── + + private data class CarrierResult( + val dhiraagu: DhiraaguClient.Result, + val ooredoo: OoredooClient.CustType + ) + + private fun lookupCarrier(number: String) { + fragment.startLookupLoading() + fragment.viewLifecycleOwner.lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { queryCarriers(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) + } + } + + /** + * Asks the likelier carrier first based on the leading digit and only falls back to the + * other when the first says it doesn't know the number. Blocking — call from IO. + */ + private fun queryCarriers(number: String): CarrierResult = + if (number.startsWith("7")) { + // Dhiraagu first, fall back to Ooredoo + val d = dhiraagu(number) + val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number) + else OoredooClient.CustType.UNSUPPORTED + CarrierResult(d, o) + } else { + // Ooredoo first, fall back to Dhiraagu + val o = ooredoo(number) + val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number) + else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) + CarrierResult(d, o) + } + + private fun dhiraagu(number: String) = + try { DhiraaguClient().validateNumber(number) } + catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) } + + private fun ooredoo(number: String) = + try { OoredooClient().validateNumber(number) } + catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED } + + private fun servicesFor(result: CarrierResult): List = buildList { + if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD) + if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL) + 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, + 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 + ) + } +}