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