split fahipay transfer
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-09-21 07:41:36 +05:00
parent f59c2be6c4
commit ca6ecc4283
2 changed files with 212 additions and 118 deletions
@@ -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.
@@ -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<FahipayService> = 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<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
)
}
}